diff --git a/scripts/langindex.json b/scripts/langindex.json
index 3dd04a77c..db5d12893 100644
--- a/scripts/langindex.json
+++ b/scripts/langindex.json
@@ -594,6 +594,7 @@
"addon.mod_forum.errorgetforum": "local_moodlemobileapp",
"addon.mod_forum.errorgetgroups": "local_moodlemobileapp",
"addon.mod_forum.errorposttoallgroups": "local_moodlemobileapp",
+ "addon.mod_forum.favourites": "forum",
"addon.mod_forum.favouriteupdated": "forum",
"addon.mod_forum.forumnodiscussionsyet": "local_moodlemobileapp",
"addon.mod_forum.group": "local_moodlemobileapp",
@@ -1004,6 +1005,10 @@
"addon.mod_workshop.switchphase30": "workshop",
"addon.mod_workshop.switchphase40": "workshop",
"addon.mod_workshop.switchphase50": "workshop",
+ "addon.mod_workshop.taskdone": "workshop",
+ "addon.mod_workshop.taskfail": "workshop",
+ "addon.mod_workshop.taskinfo": "workshop",
+ "addon.mod_workshop.tasktodo": "workshop",
"addon.mod_workshop.userplan": "workshop",
"addon.mod_workshop.userplancurrentphase": "workshop",
"addon.mod_workshop.warningassessmentmodified": "local_moodlemobileapp",
@@ -1044,12 +1049,15 @@
"addon.notifications.notifications": "local_moodlemobileapp",
"addon.notifications.playsound": "local_moodlemobileapp",
"addon.notifications.therearentnotificationsyet": "local_moodlemobileapp",
+ "addon.notifications.unreadnotification": "message",
"addon.privatefiles.couldnotloadfiles": "local_moodlemobileapp",
"addon.privatefiles.emptyfilelist": "local_moodlemobileapp",
"addon.privatefiles.erroruploadnotworking": "local_moodlemobileapp",
"addon.privatefiles.files": "moodle",
"addon.privatefiles.privatefiles": "moodle",
"addon.privatefiles.sitefiles": "moodle",
+ "addon.qtype_essay.maxwordlimitboundary": "qtype_essay",
+ "addon.qtype_essay.minwordlimitboundary": "qtype_essay",
"addon.storagemanager.deletecourse": "local_moodlemobileapp",
"addon.storagemanager.deletecourses": "local_moodlemobileapp",
"addon.storagemanager.deletedatafrom": "local_moodlemobileapp",
@@ -1389,6 +1397,7 @@
"core.clicktohideshow": "moodle",
"core.clicktoseefull": "local_moodlemobileapp",
"core.close": "repository",
+ "core.collapse": "moodle",
"core.comments": "moodle",
"core.comments.addcomment": "moodle",
"core.comments.comments": "moodle",
@@ -1573,6 +1582,7 @@
"core.errorsyncblocked": "local_moodlemobileapp",
"core.errorurlschemeinvalidscheme": "local_moodlemobileapp",
"core.errorurlschemeinvalidsite": "local_moodlemobileapp",
+ "core.expand": "moodle",
"core.explanationdigitalminor": "moodle",
"core.favourites": "moodle",
"core.filename": "repository",
@@ -1610,16 +1620,22 @@
"core.forcepasswordchangenotice": "moodle",
"core.fulllistofcourses": "moodle",
"core.fullnameandsitename": "local_moodlemobileapp",
+ "core.grades.aggregatemean": "grades",
+ "core.grades.aggregatesum": "grades",
"core.grades.average": "grades",
"core.grades.badgrade": "grades",
+ "core.grades.calculatedgrade": "grades",
+ "core.grades.category": "grades",
"core.grades.contributiontocoursetotal": "grades",
"core.grades.feedback": "grades",
"core.grades.grade": "grades",
"core.grades.gradeitem": "grades",
"core.grades.grades": "grades",
"core.grades.lettergrade": "grades",
+ "core.grades.manualitem": "grades",
"core.grades.nogradesreturned": "grades",
"core.grades.nooutcome": "grades",
+ "core.grades.outcome": "grades",
"core.grades.percentage": "grades",
"core.grades.range": "grades",
"core.grades.rank": "grades",
@@ -2138,6 +2154,7 @@
"core.time": "moodle",
"core.timesup": "quiz",
"core.today": "moodle",
+ "core.toggledelete": "local_moodlemobileapp",
"core.tryagain": "local_moodlemobileapp",
"core.twoparagraphs": "local_moodlemobileapp",
"core.uhoh": "local_moodlemobileapp",
diff --git a/src/addons/qtype/essay/component/addon-qtype-essay.html b/src/addons/qtype/essay/component/addon-qtype-essay.html
index a6b80ce10..820882739 100644
--- a/src/addons/qtype/essay/component/addon-qtype-essay.html
+++ b/src/addons/qtype/essay/component/addon-qtype-essay.html
@@ -79,6 +79,15 @@
+
+
+
+
+
+
+
+
diff --git a/src/addons/qtype/essay/lang.json b/src/addons/qtype/essay/lang.json
new file mode 100644
index 000000000..e0ed42573
--- /dev/null
+++ b/src/addons/qtype/essay/lang.json
@@ -0,0 +1,4 @@
+{
+ "maxwordlimitboundary": "The word limit for this question is {{$a.limit}} words and you are attempting to submit {{$a.count}} words. Please shorten your response and try again.",
+ "minwordlimitboundary": "This question requires a response of at least {{$a.limit}} words and you are attempting to submit {{$a.count}} words. Please expand your response and try again."
+}
diff --git a/src/addons/qtype/essay/services/handlers/essay.ts b/src/addons/qtype/essay/services/handlers/essay.ts
index e651af56b..cb6c91646 100644
--- a/src/addons/qtype/essay/services/handlers/essay.ts
+++ b/src/addons/qtype/essay/services/handlers/essay.ts
@@ -26,7 +26,7 @@ import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
import { CoreWSFile } from '@services/ws';
-import { makeSingleton } from '@singletons';
+import { makeSingleton, Translate } from '@singletons';
import { AddonQtypeEssayComponent } from '../../component/essay';
/**
@@ -155,6 +155,63 @@ export class AddonQtypeEssayHandlerService implements CoreQuestionHandler {
}
}
+ /**
+ * @inheritdoc
+ */
+ getValidationError(
+ question: CoreQuestionQuestionParsed,
+ answers: CoreQuestionsAnswers,
+ onlineError: string | undefined,
+ ): string | undefined {
+ if (answers.answer === undefined) {
+ // Not answered in offline.
+ return onlineError;
+ }
+
+ if (!answers.answer) {
+ // Not answered yet, no error.
+ return;
+ }
+
+ return this.checkInputWordCount(question, answers.answer, onlineError);
+ }
+
+ /**
+ * Check the input word count and return a message to user when the number of words are outside the boundary settings.
+ *
+ * @param question The question.
+ * @param answers Object with the question answers (without prefix).
+ * @param onlineError Online validation error.
+ * @return Error message if there's a validation error, undefined otherwise.
+ */
+ protected checkInputWordCount(
+ question: CoreQuestionQuestionParsed,
+ answer: string,
+ onlineError: string | undefined,
+ ): string | undefined {
+ if (!question.parsedSettings || question.parsedSettings.maxwordlimit === undefined ||
+ question.parsedSettings.minwordlimit === undefined) {
+ // Min/max not supported, use online error.
+ return onlineError;
+ }
+
+ const minWords = Number(question.parsedSettings.minwordlimit);
+ const maxWords = Number(question.parsedSettings.maxwordlimit);
+
+ if (!maxWords && !minWords) {
+ // No min and max, no error.
+ return;
+ }
+
+ // Count the number of words in the response string.
+ const count = CoreTextUtils.countWords(answer);
+ if (maxWords && count > maxWords) {
+ return Translate.instant('addon.qtype_essay.maxwordlimitboundary', { $a: { limit: maxWords, count: count } });
+ } else if (count < minWords) {
+ return Translate.instant('addon.qtype_essay.minwordlimitboundary', { $a: { limit: minWords, count: count } });
+ }
+ }
+
/**
* Check if a response is complete.
*
@@ -175,6 +232,10 @@ export class AddonQtypeEssayHandlerService implements CoreQuestionHandler {
const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
const allowedOptions = this.getAllowedOptions(question);
+ if (hasTextAnswer && this.checkInputWordCount(question, answers.answer, undefined)) {
+ return 0;
+ }
+
if (!allowedOptions.attachments) {
return hasTextAnswer ? 1 : 0;
}
diff --git a/src/core/features/question/classes/base-question-component.ts b/src/core/features/question/classes/base-question-component.ts
index dcb5f9458..2c8013b83 100644
--- a/src/core/features/question/classes/base-question-component.ts
+++ b/src/core/features/question/classes/base-question-component.ts
@@ -264,6 +264,7 @@ export class CoreQuestionBaseComponent {
if (review) {
// Search the answer and the attachments.
question.answer = CoreDomUtils.getContentsOfElement(questionEl, '.qtype_essay_response');
+ question.wordCountInfo = questionEl.querySelector('.answer > p')?.innerHTML;
if (question.parsedSettings) {
question.attachments = Array.from(
@@ -764,6 +765,7 @@ export type AddonModQuizEssayQuestion = AddonModQuizQuestionBasicData & {
attachmentsMaxBytes?: number; // Max bytes for attachments.
answerPlagiarism?: string; // Plagiarism HTML for the answer.
attachmentsPlagiarisms?: string[]; // Plagiarism HTML for each attachment.
+ wordCountInfo?: string; // Info about word count.
};
/**
diff --git a/src/core/features/question/classes/base-question-handler.ts b/src/core/features/question/classes/base-question-handler.ts
index a7a53b446..bd062e645 100644
--- a/src/core/features/question/classes/base-question-handler.ts
+++ b/src/core/features/question/classes/base-question-handler.ts
@@ -75,6 +75,17 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler {
return;
}
+ /**
+ * @inheritdoc
+ */
+ getValidationError(
+ question: CoreQuestionQuestionParsed,
+ answers: CoreQuestionsAnswers,
+ onlineError: string,
+ ): string | undefined {
+ return onlineError;
+ }
+
/**
* Check if a response is complete.
*
diff --git a/src/core/features/question/components/question/question.ts b/src/core/features/question/components/question/question.ts
index 0887de6bc..4d17d9ef4 100644
--- a/src/core/features/question/components/question/question.ts
+++ b/src/core/features/question/components/question/question.ts
@@ -128,18 +128,26 @@ export class CoreQuestionComponent implements OnInit {
return;
}
- // Load local answers if offline is enabled.
- if (this.offlineEnabled && this.component && this.attemptId) {
- await CoreQuestionHelper.loadLocalAnswers(this.question, this.component, this.attemptId);
- } else {
- this.question.localAnswers = {};
- }
-
CoreQuestionHelper.extractQbehaviourRedoButton(this.question);
// Extract the validation error of the question.
this.validationError = CoreQuestionHelper.getValidationErrorFromHtml(this.question.html);
+ // Load local answers if offline is enabled.
+ if (this.offlineEnabled && this.component && this.attemptId) {
+ await CoreQuestionHelper.loadLocalAnswers(this.question, this.component, this.attemptId);
+
+ this.validationError = CoreQuestionDelegate.getValidationError(
+ this.question,
+ this.question.localAnswers || {},
+ this.validationError,
+ this.component,
+ this.attemptId,
+ );
+ } else {
+ this.question.localAnswers = {};
+ }
+
// Load the local answers in the HTML.
CoreQuestionHelper.loadLocalAnswersInHtml(this.question);
diff --git a/src/core/features/question/services/question-delegate.ts b/src/core/features/question/services/question-delegate.ts
index ff3d1bc01..f9a3ab2cb 100644
--- a/src/core/features/question/services/question-delegate.ts
+++ b/src/core/features/question/services/question-delegate.ts
@@ -57,6 +57,24 @@ export interface CoreQuestionHandler extends CoreDelegateHandler {
*/
getPreventSubmitMessage?(question: CoreQuestionQuestionParsed): string | undefined;
+ /**
+ * Check if there's a validation error with the offline data.
+ *
+ * @param question The question.
+ * @param answers Object with the question offline answers (without prefix).
+ * @param onlineError Online validation error.
+ * @param component The component the question is related to.
+ * @param componentId Component ID.
+ * @return Error message if there's a validation error, undefined otherwise.
+ */
+ getValidationError?(
+ question: CoreQuestionQuestionParsed,
+ answers: CoreQuestionsAnswers,
+ onlineError: string | undefined,
+ component: string,
+ componentId: string | number,
+ ): string | undefined;
+
/**
* Check if a response is complete.
*
@@ -455,6 +473,28 @@ export class CoreQuestionDelegateService extends CoreDelegate