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:
Rotimi Best
2026-03-29 09:50:24 +01:00
parent acccbee110
commit ff2ad59377
24 changed files with 805 additions and 88 deletions
@@ -291,6 +291,18 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
'textarea.edit.placeholder': t.get(
'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(
'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.placeholder'
),
@@ -4,7 +4,7 @@
import { fly } from 'svelte/transition';
import { cubicInOut } from 'svelte/easing';
import { courseApi } from '$features/course/api';
import { questionnaire, questionnaireMetaData } from './store';
import { questionnaire, questionnaireMetaData, resetStudentExerciseTake } from './store';
import Preview from './preview.svelte';
import { ExerciseQuestion } from '@cio/ui';
import * as Alert from '@cio/ui/base/alert';
@@ -18,34 +18,49 @@
import { STATUS } from './constants';
import { filterOutDeleted } from './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 type { SubmissionListItem } from '$features/course/utils/types';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
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 { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels';
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 {
preview?: boolean;
exerciseId?: string;
isFetchingExercise?: boolean;
/** Current user's submission from server (no client fetch) */
mySubmission?: SubmissionListItem | null;
/** Current user's submissions (ordered by attempt) */
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);
/** When true, do not re-apply a past submission over a fresh take (Try again). */
let skipHydrateFromSubmissions = $state(false);
let isLoadingAutoSavedData = $state(false);
let alreadyCheckedAutoSavedData = $state(false);
let prevExerciseId = $state('');
let slideDirection = $state<'next' | 'prev'>('next');
let selectedTryIndex = $state(-1);
const questionLabels = $derived(getExerciseQuestionLabels());
const submissionList = $derived(Array.isArray(mySubmissions) ? mySubmissions : []);
function handleStart() {
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex + 1 }));
}
@@ -116,20 +131,39 @@
return;
}
await exerciseApi.submit(courseApi.course?.id!, exerciseId, answersForApi);
const submitResult = await exerciseApi.submit(courseApi.course.id, exerciseId, answersForApi);
if (exerciseApi.success) {
questionnaireMetaData.update((m) => ({
...m,
status: 1,
totalPossibleGrade,
grades: {},
comment: '',
isFinished: true,
exerciseId
}));
if (exerciseApi.success && submitResult?.data) {
skipHydrateFromSubmissions = false;
const sub = submitResult.data as unknown as SubmissionListItem & { gradingState?: string };
if (sub.gradingState === 'completed' && sub.statusId === STATUS.GRADED && Array.isArray(sub.answers)) {
applyMySubmission(sub);
} else {
questionnaireMetaData.update((m) => ({
...m,
status: 1,
totalPossibleGrade,
grades: {},
comment: '',
isFinished: true,
exerciseId
}));
}
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;
} else {
// Advance to next question
@@ -208,6 +242,7 @@
case 'CHECKBOX':
return answerValue.optionIds.length > 0;
case 'TEXTAREA':
return getTextareaAnswerCharacterCount(answerValue.text) > 0;
case 'SHORT_ANSWER':
return answerValue.text.trim().length > 0;
case 'FILL_BLANK':
@@ -249,6 +284,12 @@
return;
}
const textareaError = getTextareaValidationError(valueToUse);
if (textareaError) {
snackbar.error(textareaError);
return;
}
onSubmit(sharedCurrentQuestionKey, valueToUse);
}
@@ -256,7 +297,7 @@
if (e.key !== 'Enter' || isSubmitting || !currentQuestion) return;
const target = e.target as HTMLElement;
if (target?.tagName === 'TEXTAREA') return;
if (target?.tagName === 'TEXTAREA' || isTextEditorTarget(target)) return;
e.preventDefault();
@@ -283,6 +324,42 @@
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(() => {
if (!alreadyCheckedAutoSavedData) {
getAutoSavedData();
@@ -322,10 +399,39 @@
});
$effect(() => {
if (isFetchingExercise || !mySubmission || $questionnaireMetaData.isFinished) return;
applyMySubmission(mySubmission);
if (!exerciseId || isFetchingExercise || skipHydrateFromSubmissions) return;
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) {
const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
let finalTotalGrade = 0;
@@ -360,6 +466,8 @@
if (!!prevExerciseId) {
isSubmitting = false;
alreadyCheckedAutoSavedData = false;
skipHydrateFromSubmissions = false;
selectedTryIndex = -1;
}
prevExerciseId = exerciseId;
});
@@ -474,12 +582,47 @@
{/if}
</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
questions={$questionnaire.questions.sort((a, b) => a.order - b.order)}
questionnaireMetaData={$questionnaireMetaData}
grades={$questionnaireMetaData.grades}
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}
{:else if currentQuestion && currentQuestion?.id}
<div class="flex flex-col gap-4">
@@ -520,7 +520,7 @@
"send_email": "Send invitationsmail til importerede brugere",
"submit": "Importer brugere",
"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_failed": "Kunne ikke importere brugere"
}
@@ -1164,10 +1164,17 @@
"shared_question": {
"textarea": {
"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": {
"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": {
@@ -520,7 +520,7 @@
"send_email": "Senden Sie Einladungs-E-Mails an importierte Benutzer",
"submit": "Benutzer importieren",
"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_failed": "Benutzer konnten nicht importiert werden"
}
@@ -1164,10 +1164,17 @@
"shared_question": {
"textarea": {
"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": {
"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": {
@@ -983,6 +983,18 @@
"deadline_helper": "Students must meet the completion percentage before this datetime (local time). Leave empty for no deadline.",
"threshold_label": "Minimum completion (%)",
"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_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!"
@@ -1268,6 +1280,10 @@
"all_exercises": {
"heading": "All Exercise",
"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",
"save": "Save",
"preview": "Preview",
@@ -1340,6 +1356,10 @@
}
},
"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",
"empty_description": "Click the Edit button to add.",
"empty_edit_hint_prefix": "Click the ",
@@ -1578,11 +1598,18 @@
}
},
"textarea": {
"edit": {
"placeholder": "Learners will answer with long-form text"
},
"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",
"submit": "Import Users",
"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_failed": "Failed to import users"
},
@@ -520,7 +520,7 @@
"send_email": "Enviar correo electrónico de invitación a usuarios importados",
"submit": "Importar usuarios",
"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_failed": "No se pudieron importar usuarios"
}
@@ -1164,10 +1164,17 @@
"shared_question": {
"textarea": {
"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": {
"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": {
@@ -520,7 +520,7 @@
"send_email": "Envoyer un e-mail d'invitation aux utilisateurs importés",
"submit": "Importer des utilisateurs",
"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_failed": "Échec de l'importation des utilisateurs"
}
@@ -1164,10 +1164,17 @@
"shared_question": {
"textarea": {
"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": {
"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": {
@@ -520,7 +520,7 @@
"send_email": "आयातित उपयोगकर्ताओं को आमंत्रण ईमेल भेजें",
"submit": "उपयोगकर्ता आयात करें",
"back": "दर्शकों के पास वापस जाएँ",
"snackbar_success": "आयातित {आयातित} उपयोगकर्ता ({स्किप्ड} पहले से मौजूद थे, {ईमेल्ससेंट} ईमेल भेजे गए)",
"snackbar_success": "{imported} नए निमंत्रण, {assigned} मौजूदा छात्रों को पाठ्यक्रम पहुंच, {pendingInvitesRenewed} लंबित निमंत्रण नवीकृत ({emailsSent} ईमेल भेजे गए)",
"snackbar_no_emails": "कृपया कम से कम एक ईमेल पता दर्ज करें",
"snackbar_failed": "उपयोगकर्ताओं को आयात करने में विफल"
}
@@ -1164,10 +1164,17 @@
"shared_question": {
"textarea": {
"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": {
"placeholder": "शिक्षार्थी लंबे प्रारूप वाले पाठ के साथ उत्तर देंगे"
"placeholder": "शिक्षार्थी लंबे प्रारूप वाले पाठ के साथ उत्तर देंगे",
"min_characters_label": "Minimum characters",
"min_characters_placeholder": "Minimum characters",
"max_characters_label": "Maximum characters",
"max_characters_placeholder": "Maximum characters"
}
},
"hotspot": {
@@ -520,7 +520,7 @@
"send_email": "Wyślij wiadomość e-mail z zaproszeniem do zaimportowanych użytkowników",
"submit": "Importuj użytkowników",
"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_failed": "Nie udało się zaimportować użytkowników"
}
@@ -1164,10 +1164,17 @@
"shared_question": {
"textarea": {
"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": {
"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": {
@@ -520,7 +520,7 @@
"send_email": "Enviar e-mail de convite para usuários importados",
"submit": "Importar usuários",
"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_failed": "Falha ao importar usuários"
}
@@ -1164,10 +1164,17 @@
"shared_question": {
"textarea": {
"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": {
"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": {
@@ -520,7 +520,7 @@
"send_email": "Отправить электронное письмо с приглашением импортированным пользователям",
"submit": "Импортировать пользователей",
"back": "Вернуться к аудитории",
"snackbar_success": "Импортированные пользователи: {imported} ({skiped} уже существует, писем отправлено: {emailsSent})",
"snackbar_success": "{imported} новых приглашений, {assigned} существующих студентов с доступом к курсу, {pendingInvitesRenewed} обновлённых ожидающих приглашений (отправлено писем: {emailsSent})",
"snackbar_no_emails": "Пожалуйста, введите хотя бы один адрес электронной почты",
"snackbar_failed": "Не удалось импортировать пользователей."
}
@@ -1164,10 +1164,17 @@
"shared_question": {
"textarea": {
"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": {
"placeholder": "Учащиеся будут отвечать длинным текстом"
"placeholder": "Учащиеся будут отвечать длинным текстом",
"min_characters_label": "Minimum characters",
"min_characters_placeholder": "Minimum characters",
"max_characters_label": "Maximum characters",
"max_characters_placeholder": "Maximum characters"
}
},
"hotspot": {
@@ -520,7 +520,7 @@
"send_email": "Gửi email lời mời tới người dùng đã nhập",
"submit": "Nhập người dù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_failed": "Không thể nhập người dùng"
}
@@ -1164,10 +1164,17 @@
"shared_question": {
"textarea": {
"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": {
"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": {