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,4 +1,5 @@
|
|||||||
import type { AnswerData, FileUploadAnswerData } from '@cio/question-types';
|
import type { AnswerData, FileUploadAnswerData } from '@cio/question-types';
|
||||||
|
import { scoreSubmissionAnswers, type ScoreSubmissionResult, validateTextareaAnswer } from '@cio/question-types';
|
||||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||||
import type { TNewSubmission, TSubmission } from '@cio/db/types';
|
import type { TNewSubmission, TSubmission } from '@cio/db/types';
|
||||||
import type {
|
import type {
|
||||||
@@ -242,6 +243,7 @@ export async function listSubmissionsByExercise(
|
|||||||
s.answers?.length ? { ...s, answers: await enrichFileUploadAnswersArray(s.answers) } : s
|
s.answers?.length ? { ...s, answers: await enrichFileUploadAnswersArray(s.answers) } : s
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
enriched.sort((a, b) => new Date(a.createdAt ?? 0).getTime() - new Date(b.createdAt ?? 0).getTime());
|
||||||
return enriched;
|
return enriched;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
@@ -464,16 +466,24 @@ export async function listSubmissionsForGrading(courseId: string) {
|
|||||||
* @param exerciseId Exercise ID
|
* @param exerciseId Exercise ID
|
||||||
* @param submittedBy Group member ID
|
* @param submittedBy Group member ID
|
||||||
* @param answers Array of question answers
|
* @param answers Array of question answers
|
||||||
* @returns Created submission
|
* @returns Created submission (with `answers` when auto-graded on the server)
|
||||||
*/
|
*/
|
||||||
export async function createSubmissionService(
|
export async function createSubmissionService(
|
||||||
courseId: string,
|
courseId: string,
|
||||||
exerciseId: string,
|
exerciseId: string,
|
||||||
submittedBy: string,
|
submittedBy: string,
|
||||||
answers: Array<{ questionId: number; optionId?: number; answer?: string }>
|
answers: Array<{ questionId: number; optionId?: number; answer?: string }>
|
||||||
): Promise<TSubmission> {
|
): Promise<TSubmission & { answers?: any[] }> {
|
||||||
try {
|
try {
|
||||||
const exerciseWithRelations = await getExerciseWithRelationsOptimized(exerciseId);
|
const exerciseWithRelations = await getExerciseWithRelationsOptimized(exerciseId);
|
||||||
|
|
||||||
|
if (!exerciseWithRelations.exercise.allowMultipleAttempts) {
|
||||||
|
const existing = await getSubmissionsByCourseIdWithDetails(courseId, exerciseId, submittedBy);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
throw new AppError('This exercise allows only one submission', ErrorCodes.VALIDATION_ERROR, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const overallStatus = resolveOverallStatusFromTypenames(
|
const overallStatus = resolveOverallStatusFromTypenames(
|
||||||
exerciseWithRelations.questions
|
exerciseWithRelations.questions
|
||||||
.map((question) => question.questionType?.typename ?? '')
|
.map((question) => question.questionType?.typename ?? '')
|
||||||
@@ -491,30 +501,37 @@ export async function createSubmissionService(
|
|||||||
total: 0 // Will be calculated during grading
|
total: 0 // Will be calculated during grading
|
||||||
};
|
};
|
||||||
|
|
||||||
const submission = await createSubmission(submissionData);
|
let submission = await createSubmission(submissionData);
|
||||||
|
|
||||||
|
const questionById = new Map(
|
||||||
|
exerciseWithRelations.questions.map((q) => {
|
||||||
|
const questionTypeKey = QUESTION_TYPE_ID_TO_KEY[q.questionTypeId] ?? 'TEXTAREA';
|
||||||
|
return [
|
||||||
|
q.id,
|
||||||
|
{
|
||||||
|
id: q.id,
|
||||||
|
title: String(q.title ?? ''),
|
||||||
|
questionType: questionTypeKey,
|
||||||
|
points: Number(q.points ?? 0),
|
||||||
|
settings:
|
||||||
|
q.settings && typeof q.settings === 'object' && !Array.isArray(q.settings)
|
||||||
|
? (q.settings as Record<string, unknown>)
|
||||||
|
: {},
|
||||||
|
options: (q.options ?? []).map((o) => ({
|
||||||
|
id: o.id,
|
||||||
|
label: o.label ?? '',
|
||||||
|
value: o.value ?? undefined,
|
||||||
|
isCorrect: o.isCorrect
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const answerByQuestionId = new Map<number, AnswerData>();
|
||||||
|
|
||||||
// Create question answers
|
// Create question answers
|
||||||
if (answers.length > 0) {
|
if (answers.length > 0) {
|
||||||
const questionById = new Map(
|
|
||||||
exerciseWithRelations.questions.map((q) => {
|
|
||||||
const questionTypeKey = QUESTION_TYPE_ID_TO_KEY[q.questionTypeId] ?? 'TEXTAREA';
|
|
||||||
return [
|
|
||||||
q.id,
|
|
||||||
{
|
|
||||||
id: q.id,
|
|
||||||
title: q.title ?? '',
|
|
||||||
questionType: questionTypeKey,
|
|
||||||
options: (q.options ?? []).map((o) => ({
|
|
||||||
id: o.id,
|
|
||||||
label: o.label ?? '',
|
|
||||||
value: o.value ?? undefined,
|
|
||||||
isCorrect: o.isCorrect
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await db.transaction(async () => {
|
await db.transaction(async () => {
|
||||||
for (const ans of answers) {
|
for (const ans of answers) {
|
||||||
const question = questionById.get(ans.questionId);
|
const question = questionById.get(ans.questionId);
|
||||||
@@ -527,6 +544,24 @@ export async function createSubmissionService(
|
|||||||
);
|
);
|
||||||
if (!answerData) continue;
|
if (!answerData) continue;
|
||||||
|
|
||||||
|
if (answerData.type === 'TEXTAREA') {
|
||||||
|
const validation = validateTextareaAnswer(answerData.text, question);
|
||||||
|
|
||||||
|
if (!validation.isValid) {
|
||||||
|
throw new AppError(
|
||||||
|
validation.minCharacters !== undefined && validation.maxCharacters !== undefined
|
||||||
|
? `Paragraph answer must be between ${validation.minCharacters} and ${validation.maxCharacters} characters`
|
||||||
|
: validation.reason === 'below_min' && validation.minCharacters !== undefined
|
||||||
|
? `Paragraph answer must be at least ${validation.minCharacters} characters`
|
||||||
|
: `Paragraph answer must be at most ${validation.maxCharacters} characters`,
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
answerByQuestionId.set(ans.questionId, answerData);
|
||||||
|
|
||||||
await upsertQuestionAnswer({
|
await upsertQuestionAnswer({
|
||||||
submissionId: submission.id,
|
submissionId: submission.id,
|
||||||
questionId: ans.questionId,
|
questionId: ans.questionId,
|
||||||
@@ -537,6 +572,53 @@ export async function createSubmissionService(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const courseRows = await getCourseById(courseId);
|
||||||
|
const course = courseRows[0];
|
||||||
|
const shouldAutoGrade =
|
||||||
|
course?.type === 'SELF_PACED' && overallStatus === 'auto_graded' && exerciseWithRelations.questions.length > 0;
|
||||||
|
|
||||||
|
if (shouldAutoGrade) {
|
||||||
|
const answeredIds = new Set(answerByQuestionId.keys());
|
||||||
|
for (const q of exerciseWithRelations.questions) {
|
||||||
|
if (q.id == null) continue;
|
||||||
|
if (!answeredIds.has(q.id)) {
|
||||||
|
await upsertQuestionAnswer({
|
||||||
|
submissionId: submission.id,
|
||||||
|
questionId: q.id,
|
||||||
|
groupMemberId: submittedBy,
|
||||||
|
answerData: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbQuestions = exerciseWithRelations.questions
|
||||||
|
.filter((q): q is typeof q & { id: number } => q.id != null)
|
||||||
|
.map((q) => ({
|
||||||
|
id: q.id,
|
||||||
|
title: q.title,
|
||||||
|
questionTypeId: q.questionTypeId,
|
||||||
|
points: q.points != null ? Number(q.points) : 0,
|
||||||
|
settings: (q.settings as Record<string, unknown>) ?? {},
|
||||||
|
options: q.options ?? []
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { scores, total }: ScoreSubmissionResult = scoreSubmissionAnswers(dbQuestions, answerByQuestionId);
|
||||||
|
|
||||||
|
const graded = await updateSubmissionGrades(submission.id, {
|
||||||
|
answers: scores.map((s: ScoreSubmissionResult['scores'][number]) => ({
|
||||||
|
questionId: s.questionId,
|
||||||
|
points: s.points
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
statusId: 3,
|
||||||
|
gradingState: 'completed'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (graded) {
|
||||||
|
submission = graded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Send email notification to teachers
|
// Send email notification to teachers
|
||||||
try {
|
try {
|
||||||
await sendExerciseSubmissionUpdateEmail(courseId, exerciseId, submittedBy);
|
await sendExerciseSubmissionUpdateEmail(courseId, exerciseId, submittedBy);
|
||||||
@@ -545,6 +627,10 @@ export async function createSubmissionService(
|
|||||||
console.error('Failed to send exercise submission update email:', emailError);
|
console.error('Failed to send exercise submission update email:', emailError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (submission.gradingState === 'completed') {
|
||||||
|
return await getSubmission(submission.id);
|
||||||
|
}
|
||||||
|
|
||||||
return submission;
|
return submission;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
|
|||||||
@@ -291,6 +291,18 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
|||||||
'textarea.edit.placeholder': t.get(
|
'textarea.edit.placeholder': t.get(
|
||||||
'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.edit.placeholder'
|
'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.edit.placeholder'
|
||||||
),
|
),
|
||||||
|
'textarea.edit.min_characters_label': t.get(
|
||||||
|
'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.edit.min_characters_label'
|
||||||
|
),
|
||||||
|
'textarea.edit.min_characters_placeholder': t.get(
|
||||||
|
'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.edit.min_characters_placeholder'
|
||||||
|
),
|
||||||
|
'textarea.edit.max_characters_label': t.get(
|
||||||
|
'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.edit.max_characters_label'
|
||||||
|
),
|
||||||
|
'textarea.edit.max_characters_placeholder': t.get(
|
||||||
|
'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.edit.max_characters_placeholder'
|
||||||
|
),
|
||||||
'textarea.take.placeholder': t.get(
|
'textarea.take.placeholder': t.get(
|
||||||
'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.placeholder'
|
'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.placeholder'
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { cubicInOut } from 'svelte/easing';
|
import { cubicInOut } from 'svelte/easing';
|
||||||
import { courseApi } from '$features/course/api';
|
import { courseApi } from '$features/course/api';
|
||||||
import { questionnaire, questionnaireMetaData } from './store';
|
import { questionnaire, questionnaireMetaData, resetStudentExerciseTake } from './store';
|
||||||
import Preview from './preview.svelte';
|
import Preview from './preview.svelte';
|
||||||
import { ExerciseQuestion } from '@cio/ui';
|
import { ExerciseQuestion } from '@cio/ui';
|
||||||
import * as Alert from '@cio/ui/base/alert';
|
import * as Alert from '@cio/ui/base/alert';
|
||||||
@@ -18,34 +18,49 @@
|
|||||||
import { STATUS } from './constants';
|
import { STATUS } from './constants';
|
||||||
import { filterOutDeleted } from './functions';
|
import { filterOutDeleted } from './functions';
|
||||||
import { formatAnswers } from '$features/course/utils/functions';
|
import { formatAnswers } from '$features/course/utils/functions';
|
||||||
import { toApiPayload, type AnswerData } from '@cio/question-types';
|
import {
|
||||||
|
getTextareaAnswerCharacterCount,
|
||||||
|
toApiPayload,
|
||||||
|
type AnswerData,
|
||||||
|
validateTextareaAnswer
|
||||||
|
} from '@cio/question-types';
|
||||||
import { exerciseApi, presignApi } from '$features/course/api';
|
import { exerciseApi, presignApi } from '$features/course/api';
|
||||||
import type { SubmissionListItem } from '$features/course/utils/types';
|
import type { SubmissionListItem } from '$features/course/utils/types';
|
||||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
|
import { globalStore } from '$lib/utils/store/app';
|
||||||
|
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||||
import { ContentType } from '@cio/utils/constants/content';
|
import { ContentType } from '@cio/utils/constants/content';
|
||||||
import { toExerciseQuestionModel } from './question-type-utils';
|
import { toExerciseQuestionModel } from './question-type-utils';
|
||||||
import { getExerciseQuestionLabels } from './question-labels';
|
import { getExerciseQuestionLabels } from './question-labels';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
|
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
||||||
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
preview?: boolean;
|
preview?: boolean;
|
||||||
exerciseId?: string;
|
exerciseId?: string;
|
||||||
isFetchingExercise?: boolean;
|
isFetchingExercise?: boolean;
|
||||||
/** Current user's submission from server (no client fetch) */
|
/** Current user's submissions (ordered by attempt) */
|
||||||
mySubmission?: SubmissionListItem | null;
|
mySubmissions?: SubmissionListItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { preview = false, exerciseId = '', isFetchingExercise = false, mySubmission = null }: Props = $props();
|
let { preview = false, exerciseId = '', isFetchingExercise = false, mySubmissions = [] }: Props = $props();
|
||||||
|
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
|
/** When true, do not re-apply a past submission over a fresh take (Try again). */
|
||||||
|
let skipHydrateFromSubmissions = $state(false);
|
||||||
let isLoadingAutoSavedData = $state(false);
|
let isLoadingAutoSavedData = $state(false);
|
||||||
let alreadyCheckedAutoSavedData = $state(false);
|
let alreadyCheckedAutoSavedData = $state(false);
|
||||||
let prevExerciseId = $state('');
|
let prevExerciseId = $state('');
|
||||||
let slideDirection = $state<'next' | 'prev'>('next');
|
let slideDirection = $state<'next' | 'prev'>('next');
|
||||||
|
let selectedTryIndex = $state(-1);
|
||||||
const questionLabels = $derived(getExerciseQuestionLabels());
|
const questionLabels = $derived(getExerciseQuestionLabels());
|
||||||
|
|
||||||
|
const submissionList = $derived(Array.isArray(mySubmissions) ? mySubmissions : []);
|
||||||
|
|
||||||
function handleStart() {
|
function handleStart() {
|
||||||
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex + 1 }));
|
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex + 1 }));
|
||||||
}
|
}
|
||||||
@@ -116,20 +131,39 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await exerciseApi.submit(courseApi.course?.id!, exerciseId, answersForApi);
|
const submitResult = await exerciseApi.submit(courseApi.course.id, exerciseId, answersForApi);
|
||||||
|
|
||||||
if (exerciseApi.success) {
|
if (exerciseApi.success && submitResult?.data) {
|
||||||
questionnaireMetaData.update((m) => ({
|
skipHydrateFromSubmissions = false;
|
||||||
...m,
|
const sub = submitResult.data as unknown as SubmissionListItem & { gradingState?: string };
|
||||||
status: 1,
|
if (sub.gradingState === 'completed' && sub.statusId === STATUS.GRADED && Array.isArray(sub.answers)) {
|
||||||
totalPossibleGrade,
|
applyMySubmission(sub);
|
||||||
grades: {},
|
} else {
|
||||||
comment: '',
|
questionnaireMetaData.update((m) => ({
|
||||||
isFinished: true,
|
...m,
|
||||||
exerciseId
|
status: 1,
|
||||||
}));
|
totalPossibleGrade,
|
||||||
|
grades: {},
|
||||||
|
comment: '',
|
||||||
|
isFinished: true,
|
||||||
|
exerciseId
|
||||||
|
}));
|
||||||
|
}
|
||||||
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
|
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
|
||||||
|
if (
|
||||||
|
$globalStore.isStudent &&
|
||||||
|
courseApi.course?.id &&
|
||||||
|
submitResult?.data &&
|
||||||
|
'certification' in submitResult.data
|
||||||
|
) {
|
||||||
|
const certification = (submitResult.data as { certification?: { isNewCompletion?: boolean } | null })
|
||||||
|
.certification;
|
||||||
|
if (certification?.isNewCompletion) {
|
||||||
|
openCourseCompletionModal(courseApi.course.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isSubmitting = false;
|
isSubmitting = false;
|
||||||
} else {
|
} else {
|
||||||
// Advance to next question
|
// Advance to next question
|
||||||
@@ -208,6 +242,7 @@
|
|||||||
case 'CHECKBOX':
|
case 'CHECKBOX':
|
||||||
return answerValue.optionIds.length > 0;
|
return answerValue.optionIds.length > 0;
|
||||||
case 'TEXTAREA':
|
case 'TEXTAREA':
|
||||||
|
return getTextareaAnswerCharacterCount(answerValue.text) > 0;
|
||||||
case 'SHORT_ANSWER':
|
case 'SHORT_ANSWER':
|
||||||
return answerValue.text.trim().length > 0;
|
return answerValue.text.trim().length > 0;
|
||||||
case 'FILL_BLANK':
|
case 'FILL_BLANK':
|
||||||
@@ -249,6 +284,12 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textareaError = getTextareaValidationError(valueToUse);
|
||||||
|
if (textareaError) {
|
||||||
|
snackbar.error(textareaError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onSubmit(sharedCurrentQuestionKey, valueToUse);
|
onSubmit(sharedCurrentQuestionKey, valueToUse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +297,7 @@
|
|||||||
if (e.key !== 'Enter' || isSubmitting || !currentQuestion) return;
|
if (e.key !== 'Enter' || isSubmitting || !currentQuestion) return;
|
||||||
|
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target?.tagName === 'TEXTAREA') return;
|
if (target?.tagName === 'TEXTAREA' || isTextEditorTarget(target)) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -283,6 +324,42 @@
|
|||||||
onSharedNext(valueToUse);
|
onSharedNext(valueToUse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTextEditorTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
|
||||||
|
return target.closest('[contenteditable="true"], .ProseMirror') !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextareaValidationError(answerValue: AnswerData | null | undefined): string | null {
|
||||||
|
if (!sharedQuestionModel || !answerValue || answerValue.type !== 'TEXTAREA') return null;
|
||||||
|
|
||||||
|
const validation = validateTextareaAnswer(answerValue.text, sharedQuestionModel);
|
||||||
|
if (validation.isValid) return null;
|
||||||
|
|
||||||
|
if (validation.minCharacters !== undefined && validation.maxCharacters !== undefined) {
|
||||||
|
return t.get('course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.range_error', {
|
||||||
|
min: validation.minCharacters,
|
||||||
|
max: validation.maxCharacters
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.reason === 'below_min' && validation.minCharacters !== undefined) {
|
||||||
|
return t.get('course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.min_error', {
|
||||||
|
min: validation.minCharacters
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.reason === 'above_max' && validation.maxCharacters !== undefined) {
|
||||||
|
return t.get('course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.max_error', {
|
||||||
|
max: validation.maxCharacters
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!alreadyCheckedAutoSavedData) {
|
if (!alreadyCheckedAutoSavedData) {
|
||||||
getAutoSavedData();
|
getAutoSavedData();
|
||||||
@@ -322,10 +399,39 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isFetchingExercise || !mySubmission || $questionnaireMetaData.isFinished) return;
|
if (!exerciseId || isFetchingExercise || skipHydrateFromSubmissions) return;
|
||||||
applyMySubmission(mySubmission);
|
if (!submissionList.length || $questionnaireMetaData.isFinished) return;
|
||||||
|
let idx = selectedTryIndex;
|
||||||
|
if (idx < 0 || idx >= submissionList.length) {
|
||||||
|
idx = submissionList.length - 1;
|
||||||
|
selectedTryIndex = idx;
|
||||||
|
}
|
||||||
|
const sub = submissionList[idx];
|
||||||
|
if (sub) applyMySubmission(sub);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function goPrevTry() {
|
||||||
|
if (selectedTryIndex <= 0) return;
|
||||||
|
selectedTryIndex -= 1;
|
||||||
|
const sub = submissionList[selectedTryIndex];
|
||||||
|
if (sub) applyMySubmission(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNextTry() {
|
||||||
|
if (selectedTryIndex >= submissionList.length - 1) return;
|
||||||
|
selectedTryIndex += 1;
|
||||||
|
const sub = submissionList[selectedTryIndex];
|
||||||
|
if (sub) applyMySubmission(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryAgain() {
|
||||||
|
if (!$questionnaire.allowMultipleAttempts) return;
|
||||||
|
localStorage.removeItem(`autosave-exercise-${exerciseId}`);
|
||||||
|
selectedTryIndex = -1;
|
||||||
|
skipHydrateFromSubmissions = true;
|
||||||
|
resetStudentExerciseTake();
|
||||||
|
}
|
||||||
|
|
||||||
function applyMySubmission(submission: SubmissionListItem) {
|
function applyMySubmission(submission: SubmissionListItem) {
|
||||||
const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
|
const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
|
||||||
let finalTotalGrade = 0;
|
let finalTotalGrade = 0;
|
||||||
@@ -360,6 +466,8 @@
|
|||||||
if (!!prevExerciseId) {
|
if (!!prevExerciseId) {
|
||||||
isSubmitting = false;
|
isSubmitting = false;
|
||||||
alreadyCheckedAutoSavedData = false;
|
alreadyCheckedAutoSavedData = false;
|
||||||
|
skipHydrateFromSubmissions = false;
|
||||||
|
selectedTryIndex = -1;
|
||||||
}
|
}
|
||||||
prevExerciseId = exerciseId;
|
prevExerciseId = exerciseId;
|
||||||
});
|
});
|
||||||
@@ -474,12 +582,47 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if submissionList.length > 1}
|
||||||
|
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<IconButton
|
||||||
|
disabled={selectedTryIndex <= 0}
|
||||||
|
onclick={goPrevTry}
|
||||||
|
tooltip={$t('course.navItem.lessons.exercises.all_exercises.view_mode.previous_try')}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon class="h-4 w-4" />
|
||||||
|
</IconButton>
|
||||||
|
<span class="ui:text-muted-foreground text-sm">
|
||||||
|
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.attempt_counter', {
|
||||||
|
current: selectedTryIndex + 1,
|
||||||
|
total: submissionList.length
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<IconButton
|
||||||
|
disabled={selectedTryIndex >= submissionList.length - 1}
|
||||||
|
onclick={goNextTry}
|
||||||
|
tooltip={$t('course.navItem.lessons.exercises.all_exercises.view_mode.next_try')}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon class="h-4 w-4" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Preview
|
<Preview
|
||||||
questions={$questionnaire.questions.sort((a, b) => a.order - b.order)}
|
questions={$questionnaire.questions.sort((a, b) => a.order - b.order)}
|
||||||
questionnaireMetaData={$questionnaireMetaData}
|
questionnaireMetaData={$questionnaireMetaData}
|
||||||
grades={$questionnaireMetaData.grades}
|
grades={$questionnaireMetaData.grades}
|
||||||
disableGrading={true}
|
disableGrading={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RoleBasedSecurity allowedRoles={[3]}>
|
||||||
|
{#if $questionnaire.allowMultipleAttempts}
|
||||||
|
<div class="mt-4">
|
||||||
|
<Button type="button" variant="secondary" onclick={tryAgain}>
|
||||||
|
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.try_again')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</RoleBasedSecurity>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if currentQuestion && currentQuestion?.id}
|
{:else if currentQuestion && currentQuestion?.id}
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -520,7 +520,7 @@
|
|||||||
"send_email": "Send invitationsmail til importerede brugere",
|
"send_email": "Send invitationsmail til importerede brugere",
|
||||||
"submit": "Importer brugere",
|
"submit": "Importer brugere",
|
||||||
"back": "Tilbage til Publikum",
|
"back": "Tilbage til Publikum",
|
||||||
"snackbar_success": "Importerede {importerede} brugere ({skipped} eksisterede allerede, {emailsSent} e-mails sendt)",
|
"snackbar_success": "{imported} nye invitationer, {assigned} eksisterende studerende med kursusadgang, {pendingInvitesRenewed} afventende invitationer fornyet ({emailsSent} e-mails sendt)",
|
||||||
"snackbar_no_emails": "Indtast venligst mindst én e-mailadresse",
|
"snackbar_no_emails": "Indtast venligst mindst én e-mailadresse",
|
||||||
"snackbar_failed": "Kunne ikke importere brugere"
|
"snackbar_failed": "Kunne ikke importere brugere"
|
||||||
}
|
}
|
||||||
@@ -1164,10 +1164,17 @@
|
|||||||
"shared_question": {
|
"shared_question": {
|
||||||
"textarea": {
|
"textarea": {
|
||||||
"take": {
|
"take": {
|
||||||
"placeholder": "Skriv dit svar"
|
"placeholder": "Skriv dit svar",
|
||||||
|
"min_error": "Answer must be at least {min} characters.",
|
||||||
|
"max_error": "Answer must be at most {max} characters.",
|
||||||
|
"range_error": "Answer must be between {min} and {max} characters."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"placeholder": "Eleverne vil svare med lang tekst"
|
"placeholder": "Eleverne vil svare med lang tekst",
|
||||||
|
"min_characters_label": "Minimum characters",
|
||||||
|
"min_characters_placeholder": "Minimum characters",
|
||||||
|
"max_characters_label": "Maximum characters",
|
||||||
|
"max_characters_placeholder": "Maximum characters"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hotspot": {
|
"hotspot": {
|
||||||
|
|||||||
@@ -520,7 +520,7 @@
|
|||||||
"send_email": "Senden Sie Einladungs-E-Mails an importierte Benutzer",
|
"send_email": "Senden Sie Einladungs-E-Mails an importierte Benutzer",
|
||||||
"submit": "Benutzer importieren",
|
"submit": "Benutzer importieren",
|
||||||
"back": "Zurück zum Publikum",
|
"back": "Zurück zum Publikum",
|
||||||
"snackbar_success": "Importierte {imported} Benutzer ({skipped} existierte bereits, {emailsSent} E-Mails wurden gesendet)",
|
"snackbar_success": "{imported} neue Einladungen, {assigned} bestehende Studierende mit Kurszugang, {pendingInvitesRenewed} ausstehende Einladungen erneuert ({emailsSent} E-Mails gesendet)",
|
||||||
"snackbar_no_emails": "Bitte geben Sie mindestens eine E-Mail-Adresse ein",
|
"snackbar_no_emails": "Bitte geben Sie mindestens eine E-Mail-Adresse ein",
|
||||||
"snackbar_failed": "Benutzer konnten nicht importiert werden"
|
"snackbar_failed": "Benutzer konnten nicht importiert werden"
|
||||||
}
|
}
|
||||||
@@ -1164,10 +1164,17 @@
|
|||||||
"shared_question": {
|
"shared_question": {
|
||||||
"textarea": {
|
"textarea": {
|
||||||
"take": {
|
"take": {
|
||||||
"placeholder": "Schreiben Sie Ihre Antwort"
|
"placeholder": "Schreiben Sie Ihre Antwort",
|
||||||
|
"min_error": "Answer must be at least {min} characters.",
|
||||||
|
"max_error": "Answer must be at most {max} characters.",
|
||||||
|
"range_error": "Answer must be between {min} and {max} characters."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"placeholder": "Die Lernenden antworten mit einem langen Text"
|
"placeholder": "Die Lernenden antworten mit einem langen Text",
|
||||||
|
"min_characters_label": "Minimum characters",
|
||||||
|
"min_characters_placeholder": "Minimum characters",
|
||||||
|
"max_characters_label": "Maximum characters",
|
||||||
|
"max_characters_placeholder": "Maximum characters"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hotspot": {
|
"hotspot": {
|
||||||
|
|||||||
@@ -983,6 +983,18 @@
|
|||||||
"deadline_helper": "Students must meet the completion percentage before this datetime (local time). Leave empty for no deadline.",
|
"deadline_helper": "Students must meet the completion percentage before this datetime (local time). Leave empty for no deadline.",
|
||||||
"threshold_label": "Minimum completion (%)",
|
"threshold_label": "Minimum completion (%)",
|
||||||
"threshold_helper": "Combined lesson and exercise progress required to earn the certificate (0–100). Default is 100%.",
|
"threshold_helper": "Combined lesson and exercise progress required to earn the certificate (0–100). Default is 100%.",
|
||||||
|
"final_exercise_label": "Final exercise (optional)",
|
||||||
|
"final_exercise_none": "None — no extra exercise score",
|
||||||
|
"final_exercise_helper": "When set, students must reach at least the minimum score on this exercise (best attempt counts) in addition to the completion percentage above.",
|
||||||
|
"min_exercise_score_label": "Minimum score on final exercise (%)",
|
||||||
|
"min_exercise_score_helper": "Score is a percentage of the total points possible on that exercise (0–100).",
|
||||||
|
"blocker_progress": "Your course progress is {current}%; {required}% is required.",
|
||||||
|
"blocker_deadline": "The certification deadline has passed.",
|
||||||
|
"blocker_no_content": "This course has no lessons or exercises to complete.",
|
||||||
|
"blocker_final_not_submitted": "Submit the required exercise \"{exerciseTitle}\" to qualify for the certificate.",
|
||||||
|
"blocker_final_pending": "Your attempt on \"{exerciseTitle}\" is still being graded.",
|
||||||
|
"blocker_final_score": "Your best score on \"{exerciseTitle}\" is {bestPercent}%; {requiredPercent}% is required.",
|
||||||
|
"blocker_final_misconfigured": "The certification exercise \"{exerciseTitle}\" has no points configured; ask your instructor to fix it.",
|
||||||
"email_message_label": "Certification email message",
|
"email_message_label": "Certification email message",
|
||||||
"email_message_helper": "Optional message shown in the email when a student meets the requirements. A link to the certificate page is always included.",
|
"email_message_helper": "Optional message shown in the email when a student meets the requirements. A link to the certificate page is always included.",
|
||||||
"email_message_placeholder": "e.g. We are proud of your progress — keep learning!"
|
"email_message_placeholder": "e.g. We are proud of your progress — keep learning!"
|
||||||
@@ -1268,6 +1280,10 @@
|
|||||||
"all_exercises": {
|
"all_exercises": {
|
||||||
"heading": "All Exercise",
|
"heading": "All Exercise",
|
||||||
"questions": "Questions",
|
"questions": "Questions",
|
||||||
|
"settings": "Settings",
|
||||||
|
"settings_allow_multiple": "Allow multiple attempts",
|
||||||
|
"settings_allow_multiple_helper": "Students can submit more than once and review past attempts on the results screen.",
|
||||||
|
"settings_save": "Save settings",
|
||||||
"submissions": "Submissions",
|
"submissions": "Submissions",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
@@ -1340,6 +1356,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"view_mode": {
|
"view_mode": {
|
||||||
|
"attempt_counter": "Attempt {current} of {total}",
|
||||||
|
"previous_try": "Previous attempt",
|
||||||
|
"next_try": "Next attempt",
|
||||||
|
"try_again": "Try again",
|
||||||
"no_question": "No question added for this exercise",
|
"no_question": "No question added for this exercise",
|
||||||
"empty_description": "Click the Edit button to add.",
|
"empty_description": "Click the Edit button to add.",
|
||||||
"empty_edit_hint_prefix": "Click the ",
|
"empty_edit_hint_prefix": "Click the ",
|
||||||
@@ -1578,11 +1598,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"textarea": {
|
"textarea": {
|
||||||
"edit": {
|
|
||||||
"placeholder": "Learners will answer with long-form text"
|
|
||||||
},
|
|
||||||
"take": {
|
"take": {
|
||||||
"placeholder": "Write your answer"
|
"placeholder": "Write your answer",
|
||||||
|
"min_error": "Answer must be at least {min} characters.",
|
||||||
|
"max_error": "Answer must be at most {max} characters.",
|
||||||
|
"range_error": "Answer must be between {min} and {max} characters."
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"placeholder": "Learners will answer with long-form text",
|
||||||
|
"min_characters_label": "Minimum characters",
|
||||||
|
"min_characters_placeholder": "Minimum characters",
|
||||||
|
"max_characters_label": "Maximum characters",
|
||||||
|
"max_characters_placeholder": "Maximum characters"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2096,7 +2123,7 @@
|
|||||||
"send_email": "Send invitation email to imported users",
|
"send_email": "Send invitation email to imported users",
|
||||||
"submit": "Import Users",
|
"submit": "Import Users",
|
||||||
"back": "Back to Audience",
|
"back": "Back to Audience",
|
||||||
"snackbar_success": "Imported {imported} users ({skipped} already existed, {emailsSent} emails sent)",
|
"snackbar_success": "{imported} new invites, {assigned} existing students given course access, {pendingInvitesRenewed} pending invites renewed ({emailsSent} emails sent)",
|
||||||
"snackbar_no_emails": "Please enter at least one email address",
|
"snackbar_no_emails": "Please enter at least one email address",
|
||||||
"snackbar_failed": "Failed to import users"
|
"snackbar_failed": "Failed to import users"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -520,7 +520,7 @@
|
|||||||
"send_email": "Enviar correo electrónico de invitación a usuarios importados",
|
"send_email": "Enviar correo electrónico de invitación a usuarios importados",
|
||||||
"submit": "Importar usuarios",
|
"submit": "Importar usuarios",
|
||||||
"back": "Volver a la audiencia",
|
"back": "Volver a la audiencia",
|
||||||
"snackbar_success": "Usuarios importados {importados} ({skipped} ya existía, {emailsSent} correos electrónicos enviados)",
|
"snackbar_success": "{imported} invitaciones nuevas, {assigned} estudiantes existentes con acceso al curso, {pendingInvitesRenewed} invitaciones pendientes renovadas ({emailsSent} correos enviados)",
|
||||||
"snackbar_no_emails": "Por favor ingrese al menos una dirección de correo electrónico",
|
"snackbar_no_emails": "Por favor ingrese al menos una dirección de correo electrónico",
|
||||||
"snackbar_failed": "No se pudieron importar usuarios"
|
"snackbar_failed": "No se pudieron importar usuarios"
|
||||||
}
|
}
|
||||||
@@ -1164,10 +1164,17 @@
|
|||||||
"shared_question": {
|
"shared_question": {
|
||||||
"textarea": {
|
"textarea": {
|
||||||
"take": {
|
"take": {
|
||||||
"placeholder": "Escribe tu respuesta"
|
"placeholder": "Escribe tu respuesta",
|
||||||
|
"min_error": "Answer must be at least {min} characters.",
|
||||||
|
"max_error": "Answer must be at most {max} characters.",
|
||||||
|
"range_error": "Answer must be between {min} and {max} characters."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"placeholder": "Los alumnos responderán con un texto extenso."
|
"placeholder": "Los alumnos responderán con un texto extenso.",
|
||||||
|
"min_characters_label": "Minimum characters",
|
||||||
|
"min_characters_placeholder": "Minimum characters",
|
||||||
|
"max_characters_label": "Maximum characters",
|
||||||
|
"max_characters_placeholder": "Maximum characters"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hotspot": {
|
"hotspot": {
|
||||||
|
|||||||
@@ -520,7 +520,7 @@
|
|||||||
"send_email": "Envoyer un e-mail d'invitation aux utilisateurs importés",
|
"send_email": "Envoyer un e-mail d'invitation aux utilisateurs importés",
|
||||||
"submit": "Importer des utilisateurs",
|
"submit": "Importer des utilisateurs",
|
||||||
"back": "Retour au public",
|
"back": "Retour au public",
|
||||||
"snackbar_success": "Utilisateurs {importés} importés ({skipped} existait déjà, {emailsSent} e-mails envoyés)",
|
"snackbar_success": "{imported} nouvelles invitations, {assigned} étudiants existants avec accès au cours, {pendingInvitesRenewed} invitations en attente renouvelées ({emailsSent} e-mails envoyés)",
|
||||||
"snackbar_no_emails": "Veuillez saisir au moins une adresse e-mail",
|
"snackbar_no_emails": "Veuillez saisir au moins une adresse e-mail",
|
||||||
"snackbar_failed": "Échec de l'importation des utilisateurs"
|
"snackbar_failed": "Échec de l'importation des utilisateurs"
|
||||||
}
|
}
|
||||||
@@ -1164,10 +1164,17 @@
|
|||||||
"shared_question": {
|
"shared_question": {
|
||||||
"textarea": {
|
"textarea": {
|
||||||
"take": {
|
"take": {
|
||||||
"placeholder": "Écrivez votre réponse"
|
"placeholder": "Écrivez votre réponse",
|
||||||
|
"min_error": "Answer must be at least {min} characters.",
|
||||||
|
"max_error": "Answer must be at most {max} characters.",
|
||||||
|
"range_error": "Answer must be between {min} and {max} characters."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"placeholder": "Les apprenants répondront avec un texte long"
|
"placeholder": "Les apprenants répondront avec un texte long",
|
||||||
|
"min_characters_label": "Minimum characters",
|
||||||
|
"min_characters_placeholder": "Minimum characters",
|
||||||
|
"max_characters_label": "Maximum characters",
|
||||||
|
"max_characters_placeholder": "Maximum characters"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hotspot": {
|
"hotspot": {
|
||||||
|
|||||||
@@ -520,7 +520,7 @@
|
|||||||
"send_email": "आयातित उपयोगकर्ताओं को आमंत्रण ईमेल भेजें",
|
"send_email": "आयातित उपयोगकर्ताओं को आमंत्रण ईमेल भेजें",
|
||||||
"submit": "उपयोगकर्ता आयात करें",
|
"submit": "उपयोगकर्ता आयात करें",
|
||||||
"back": "दर्शकों के पास वापस जाएँ",
|
"back": "दर्शकों के पास वापस जाएँ",
|
||||||
"snackbar_success": "आयातित {आयातित} उपयोगकर्ता ({स्किप्ड} पहले से मौजूद थे, {ईमेल्ससेंट} ईमेल भेजे गए)",
|
"snackbar_success": "{imported} नए निमंत्रण, {assigned} मौजूदा छात्रों को पाठ्यक्रम पहुंच, {pendingInvitesRenewed} लंबित निमंत्रण नवीकृत ({emailsSent} ईमेल भेजे गए)",
|
||||||
"snackbar_no_emails": "कृपया कम से कम एक ईमेल पता दर्ज करें",
|
"snackbar_no_emails": "कृपया कम से कम एक ईमेल पता दर्ज करें",
|
||||||
"snackbar_failed": "उपयोगकर्ताओं को आयात करने में विफल"
|
"snackbar_failed": "उपयोगकर्ताओं को आयात करने में विफल"
|
||||||
}
|
}
|
||||||
@@ -1164,10 +1164,17 @@
|
|||||||
"shared_question": {
|
"shared_question": {
|
||||||
"textarea": {
|
"textarea": {
|
||||||
"take": {
|
"take": {
|
||||||
"placeholder": "अपना उत्तर लिखें"
|
"placeholder": "अपना उत्तर लिखें",
|
||||||
|
"min_error": "Answer must be at least {min} characters.",
|
||||||
|
"max_error": "Answer must be at most {max} characters.",
|
||||||
|
"range_error": "Answer must be between {min} and {max} characters."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"placeholder": "शिक्षार्थी लंबे प्रारूप वाले पाठ के साथ उत्तर देंगे"
|
"placeholder": "शिक्षार्थी लंबे प्रारूप वाले पाठ के साथ उत्तर देंगे",
|
||||||
|
"min_characters_label": "Minimum characters",
|
||||||
|
"min_characters_placeholder": "Minimum characters",
|
||||||
|
"max_characters_label": "Maximum characters",
|
||||||
|
"max_characters_placeholder": "Maximum characters"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hotspot": {
|
"hotspot": {
|
||||||
|
|||||||
@@ -520,7 +520,7 @@
|
|||||||
"send_email": "Wyślij wiadomość e-mail z zaproszeniem do zaimportowanych użytkowników",
|
"send_email": "Wyślij wiadomość e-mail z zaproszeniem do zaimportowanych użytkowników",
|
||||||
"submit": "Importuj użytkowników",
|
"submit": "Importuj użytkowników",
|
||||||
"back": "Powrót do Publiczności",
|
"back": "Powrót do Publiczności",
|
||||||
"snackbar_success": "Zaimportowani użytkownicy: {imported} ({skipped} już istniało, wysłano e-maile: {emailsSent})",
|
"snackbar_success": "{imported} nowych zaproszeń, {assigned} istniejących studentów z dostępem do kursu, {pendingInvitesRenewed} odnowionych oczekujących zaproszeń (wysłano e-maile: {emailsSent})",
|
||||||
"snackbar_no_emails": "Proszę podać przynajmniej jeden adres e-mail",
|
"snackbar_no_emails": "Proszę podać przynajmniej jeden adres e-mail",
|
||||||
"snackbar_failed": "Nie udało się zaimportować użytkowników"
|
"snackbar_failed": "Nie udało się zaimportować użytkowników"
|
||||||
}
|
}
|
||||||
@@ -1164,10 +1164,17 @@
|
|||||||
"shared_question": {
|
"shared_question": {
|
||||||
"textarea": {
|
"textarea": {
|
||||||
"take": {
|
"take": {
|
||||||
"placeholder": "Napisz swoją odpowiedź"
|
"placeholder": "Napisz swoją odpowiedź",
|
||||||
|
"min_error": "Answer must be at least {min} characters.",
|
||||||
|
"max_error": "Answer must be at most {max} characters.",
|
||||||
|
"range_error": "Answer must be between {min} and {max} characters."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"placeholder": "Uczniowie będą odpowiadać za pomocą długiego tekstu"
|
"placeholder": "Uczniowie będą odpowiadać za pomocą długiego tekstu",
|
||||||
|
"min_characters_label": "Minimum characters",
|
||||||
|
"min_characters_placeholder": "Minimum characters",
|
||||||
|
"max_characters_label": "Maximum characters",
|
||||||
|
"max_characters_placeholder": "Maximum characters"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hotspot": {
|
"hotspot": {
|
||||||
|
|||||||
@@ -520,7 +520,7 @@
|
|||||||
"send_email": "Enviar e-mail de convite para usuários importados",
|
"send_email": "Enviar e-mail de convite para usuários importados",
|
||||||
"submit": "Importar usuários",
|
"submit": "Importar usuários",
|
||||||
"back": "Voltar ao público",
|
"back": "Voltar ao público",
|
||||||
"snackbar_success": "Usuários {importados} importados ({skipped} já existia, {emailsSent} emails enviados)",
|
"snackbar_success": "{imported} novos convites, {assigned} estudantes existentes com acesso ao curso, {pendingInvitesRenewed} convites pendentes renovados ({emailsSent} e-mails enviados)",
|
||||||
"snackbar_no_emails": "Insira pelo menos um endereço de e-mail",
|
"snackbar_no_emails": "Insira pelo menos um endereço de e-mail",
|
||||||
"snackbar_failed": "Falha ao importar usuários"
|
"snackbar_failed": "Falha ao importar usuários"
|
||||||
}
|
}
|
||||||
@@ -1164,10 +1164,17 @@
|
|||||||
"shared_question": {
|
"shared_question": {
|
||||||
"textarea": {
|
"textarea": {
|
||||||
"take": {
|
"take": {
|
||||||
"placeholder": "Escreva sua resposta"
|
"placeholder": "Escreva sua resposta",
|
||||||
|
"min_error": "Answer must be at least {min} characters.",
|
||||||
|
"max_error": "Answer must be at most {max} characters.",
|
||||||
|
"range_error": "Answer must be between {min} and {max} characters."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"placeholder": "Os alunos responderão com texto longo"
|
"placeholder": "Os alunos responderão com texto longo",
|
||||||
|
"min_characters_label": "Minimum characters",
|
||||||
|
"min_characters_placeholder": "Minimum characters",
|
||||||
|
"max_characters_label": "Maximum characters",
|
||||||
|
"max_characters_placeholder": "Maximum characters"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hotspot": {
|
"hotspot": {
|
||||||
|
|||||||
@@ -520,7 +520,7 @@
|
|||||||
"send_email": "Отправить электронное письмо с приглашением импортированным пользователям",
|
"send_email": "Отправить электронное письмо с приглашением импортированным пользователям",
|
||||||
"submit": "Импортировать пользователей",
|
"submit": "Импортировать пользователей",
|
||||||
"back": "Вернуться к аудитории",
|
"back": "Вернуться к аудитории",
|
||||||
"snackbar_success": "Импортированные пользователи: {imported} ({skiped} уже существует, писем отправлено: {emailsSent})",
|
"snackbar_success": "{imported} новых приглашений, {assigned} существующих студентов с доступом к курсу, {pendingInvitesRenewed} обновлённых ожидающих приглашений (отправлено писем: {emailsSent})",
|
||||||
"snackbar_no_emails": "Пожалуйста, введите хотя бы один адрес электронной почты",
|
"snackbar_no_emails": "Пожалуйста, введите хотя бы один адрес электронной почты",
|
||||||
"snackbar_failed": "Не удалось импортировать пользователей."
|
"snackbar_failed": "Не удалось импортировать пользователей."
|
||||||
}
|
}
|
||||||
@@ -1164,10 +1164,17 @@
|
|||||||
"shared_question": {
|
"shared_question": {
|
||||||
"textarea": {
|
"textarea": {
|
||||||
"take": {
|
"take": {
|
||||||
"placeholder": "Напишите свой ответ"
|
"placeholder": "Напишите свой ответ",
|
||||||
|
"min_error": "Answer must be at least {min} characters.",
|
||||||
|
"max_error": "Answer must be at most {max} characters.",
|
||||||
|
"range_error": "Answer must be between {min} and {max} characters."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"placeholder": "Учащиеся будут отвечать длинным текстом"
|
"placeholder": "Учащиеся будут отвечать длинным текстом",
|
||||||
|
"min_characters_label": "Minimum characters",
|
||||||
|
"min_characters_placeholder": "Minimum characters",
|
||||||
|
"max_characters_label": "Maximum characters",
|
||||||
|
"max_characters_placeholder": "Maximum characters"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hotspot": {
|
"hotspot": {
|
||||||
|
|||||||
@@ -520,7 +520,7 @@
|
|||||||
"send_email": "Gửi email lời mời tới người dùng đã nhập",
|
"send_email": "Gửi email lời mời tới người dùng đã nhập",
|
||||||
"submit": "Nhập người dùng",
|
"submit": "Nhập người dùng",
|
||||||
"back": "Quay lại Đối tượng",
|
"back": "Quay lại Đối tượng",
|
||||||
"snackbar_success": "Đã nhập {imported} người dùng ({skipped} đã tồn tại, đã gửi email {emailsSent})",
|
"snackbar_success": "{imported} lời mời mới, {assigned} học viên hiện có được quyền truy cập khóa học, {pendingInvitesRenewed} lời mời đang chờ được gia hạn ({emailsSent} email đã gửi)",
|
||||||
"snackbar_no_emails": "Vui lòng nhập ít nhất một địa chỉ email",
|
"snackbar_no_emails": "Vui lòng nhập ít nhất một địa chỉ email",
|
||||||
"snackbar_failed": "Không thể nhập người dùng"
|
"snackbar_failed": "Không thể nhập người dùng"
|
||||||
}
|
}
|
||||||
@@ -1164,10 +1164,17 @@
|
|||||||
"shared_question": {
|
"shared_question": {
|
||||||
"textarea": {
|
"textarea": {
|
||||||
"take": {
|
"take": {
|
||||||
"placeholder": "Viết câu trả lời của bạn"
|
"placeholder": "Viết câu trả lời của bạn",
|
||||||
|
"min_error": "Answer must be at least {min} characters.",
|
||||||
|
"max_error": "Answer must be at most {max} characters.",
|
||||||
|
"range_error": "Answer must be between {min} and {max} characters."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"placeholder": "Người học sẽ trả lời bằng văn bản dài"
|
"placeholder": "Người học sẽ trả lời bằng văn bản dài",
|
||||||
|
"min_characters_label": "Minimum characters",
|
||||||
|
"min_characters_placeholder": "Minimum characters",
|
||||||
|
"max_characters_label": "Maximum characters",
|
||||||
|
"max_characters_placeholder": "Maximum characters"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hotspot": {
|
"hotspot": {
|
||||||
|
|||||||
@@ -1,14 +1,40 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
import { SQL, sql } from 'drizzle-orm';
|
import { SQL, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres, { type Sql } from 'postgres';
|
||||||
|
|
||||||
// import * as schema from './schema';
|
// import * as schema from './schema';
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL ?? process.env.PRIVATE_DATABASE_URL ?? '';
|
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 DbClient = typeof db;
|
||||||
export type TxClient = Parameters<typeof db.transaction>[0] extends (tx: infer T) => any ? T : never;
|
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.take.placeholder'
|
||||||
| 'hotspot.preview.helper'
|
| 'hotspot.preview.helper'
|
||||||
| 'textarea.edit.placeholder'
|
| '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'
|
| 'textarea.take.placeholder'
|
||||||
| 'submission.common.no_answer'
|
| 'submission.common.no_answer'
|
||||||
| 'submission.common.other'
|
| 'submission.common.other'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export * from './question-type-lookup';
|
|||||||
export * from './question-type-capabilities';
|
export * from './question-type-capabilities';
|
||||||
export * from './question-answer-payload';
|
export * from './question-answer-payload';
|
||||||
export * from './question-scoring-rules';
|
export * from './question-scoring-rules';
|
||||||
|
export * from './question-scoring';
|
||||||
|
|
||||||
export * from './exercise-types';
|
export * from './exercise-types';
|
||||||
export * from './render-contract';
|
export * from './render-contract';
|
||||||
@@ -12,3 +13,4 @@ export * from './form-state';
|
|||||||
export * from './answer-data';
|
export * from './answer-data';
|
||||||
export * from './answer-codecs';
|
export * from './answer-codecs';
|
||||||
export * from './renderer-registry';
|
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]',
|
title: 'Explain why exercise feedback should be specific. [TEXTAREA]',
|
||||||
questionType: 'TEXTAREA',
|
questionType: 'TEXTAREA',
|
||||||
settings: {
|
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>'
|
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.take.placeholder': 'Provide hotspot coordinates as JSON',
|
||||||
'hotspot.preview.helper': 'Hotspot preview is provided by host app image and hotspot settings.',
|
'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.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',
|
'textarea.take.placeholder': 'Write your answer',
|
||||||
'submission.common.no_answer': 'No answer',
|
'submission.common.no_answer': 'No answer',
|
||||||
'submission.common.other': 'Other',
|
'submission.common.other': 'Other',
|
||||||
|
|||||||
@@ -49,6 +49,14 @@
|
|||||||
// Browser detection
|
// Browser detection
|
||||||
const browser = typeof window !== 'undefined';
|
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
|
// Handle content persistence
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (enablePersistence && browser && content) {
|
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 }) {
|
function onUpdate(props: { editor: Editor; transaction: Transaction }) {
|
||||||
if (props?.editor && !props.editor.isDestroyed) {
|
if (props?.editor && !props.editor.isDestroyed) {
|
||||||
const newContent = props.editor.getHTML();
|
const newContent = props.editor.getHTML();
|
||||||
|
|||||||
@@ -1,12 +1,68 @@
|
|||||||
<script lang="ts">
|
<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';
|
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 = '') =>
|
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||||
getExerciseQuestionLabel(labels, key, 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>
|
</script>
|
||||||
|
|
||||||
<div class="ui:space-y-3">
|
<div class="ui:space-y-3">
|
||||||
<Textarea class="ui:w-full" rows={4} disabled={true} placeholder={label('textarea.edit.placeholder')} />
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import type { Editor } from '@tiptap/core';
|
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';
|
import { Editor as EditorComponent } from '../../../editor';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -16,16 +21,109 @@
|
|||||||
getExerciseQuestionLabel(labels, key, fallback);
|
getExerciseQuestionLabel(labels, key, fallback);
|
||||||
|
|
||||||
const content = $derived(answer?.type === 'TEXTAREA' ? answer.text : String(answer ?? ''));
|
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) {
|
function handleEditorReady(editor: Editor) {
|
||||||
if (!editor?.view?.dom || disabled) return;
|
detachEditorListeners?.();
|
||||||
|
|
||||||
|
const editorDom = getEditorDom(editor);
|
||||||
|
|
||||||
|
if (!editorDom || disabled) return;
|
||||||
|
|
||||||
const blurHandler = () => {
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -36,7 +134,13 @@
|
|||||||
showToolBar={!disabled}
|
showToolBar={!disabled}
|
||||||
placeholder={label('textarea.take.placeholder')}
|
placeholder={label('textarea.take.placeholder')}
|
||||||
editorClass="ui:h-40"
|
editorClass="ui:h-40"
|
||||||
onContentChange={(nextContent) => onAnswerChange({ type: 'TEXTAREA', text: String(nextContent ?? '') })}
|
onContentChange={updateAnswer}
|
||||||
onEditorReady={handleEditorReady}
|
onEditorReady={handleEditorReady}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{#if shouldShowCharacterCount}
|
||||||
|
<span class="ui:text-muted-foreground ui:block ui:text-right ui:text-xs">
|
||||||
|
{characterCount} / {characterLimits.maxCharacters}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user