diff --git a/src/addon/qtype/calculated/component/calculated.ts b/src/addon/qtype/calculated/component/calculated.ts index 585d317ff..e42c6625c 100644 --- a/src/addon/qtype/calculated/component/calculated.ts +++ b/src/addon/qtype/calculated/component/calculated.ts @@ -12,10 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, Injector } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; -import { CoreDomUtilsProvider } from '@providers/utils/dom'; -import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component'; /** @@ -27,8 +25,8 @@ import { CoreQuestionBaseComponent } from '@core/question/classes/base-question- }) export class AddonQtypeCalculatedComponent extends CoreQuestionBaseComponent implements OnInit { - constructor(logger: CoreLoggerProvider, questionHelper: CoreQuestionHelperProvider, domUtils: CoreDomUtilsProvider) { - super(logger, 'AddonQtypeCalculatedComponent', questionHelper, domUtils); + constructor(logger: CoreLoggerProvider, injector: Injector) { + super(logger, 'AddonQtypeCalculatedComponent', injector); } /** diff --git a/src/addon/qtype/description/component/description.ts b/src/addon/qtype/description/component/description.ts index f08b7dc82..d54c64a05 100644 --- a/src/addon/qtype/description/component/description.ts +++ b/src/addon/qtype/description/component/description.ts @@ -12,10 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, Injector } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; -import { CoreDomUtilsProvider } from '@providers/utils/dom'; -import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component'; /** @@ -27,8 +25,8 @@ import { CoreQuestionBaseComponent } from '@core/question/classes/base-question- }) export class AddonQtypeDescriptionComponent extends CoreQuestionBaseComponent implements OnInit { - constructor(logger: CoreLoggerProvider, questionHelper: CoreQuestionHelperProvider, domUtils: CoreDomUtilsProvider) { - super(logger, 'AddonQtypeDescriptionComponent', questionHelper, domUtils); + constructor(logger: CoreLoggerProvider, injector: Injector) { + super(logger, 'AddonQtypeDescriptionComponent', injector); } /** diff --git a/src/addon/qtype/essay/component/essay.html b/src/addon/qtype/essay/component/essay.html new file mode 100644 index 000000000..1098c8304 --- /dev/null +++ b/src/addon/qtype/essay/component/essay.html @@ -0,0 +1,42 @@ +
+ + +

+
+ + + + + + + {{question.textarea.text}} + + + + + + + + +

{{ 'core.question.errorinlinefilesnotsupported' | translate }}

+
+ +

+
+
+ + + +

{{ 'core.question.errorattachmentsnotsupported' | translate }}

+
+ + + +

+
+ + + + +
diff --git a/src/addon/qtype/essay/component/essay.ts b/src/addon/qtype/essay/component/essay.ts new file mode 100644 index 000000000..c421fa629 --- /dev/null +++ b/src/addon/qtype/essay/component/essay.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, Injector } from '@angular/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component'; + +/** + * Component to render an essay question. + */ +@Component({ + selector: 'addon-qtype-essay', + templateUrl: 'essay.html' +}) +export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implements OnInit { + + constructor(logger: CoreLoggerProvider, injector: Injector) { + super(logger, 'AddonQtypeEssayComponent', injector); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.initEssayComponent(); + } +} diff --git a/src/addon/qtype/essay/essay.module.ts b/src/addon/qtype/essay/essay.module.ts new file mode 100644 index 000000000..9c3d58ba6 --- /dev/null +++ b/src/addon/qtype/essay/essay.module.ts @@ -0,0 +1,48 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreQuestionDelegate } from '@core/question/providers/delegate'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonQtypeEssayHandler } from './providers/handler'; +import { AddonQtypeEssayComponent } from './component/essay'; + +@NgModule({ + declarations: [ + AddonQtypeEssayComponent + ], + imports: [ + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule + ], + providers: [ + AddonQtypeEssayHandler + ], + exports: [ + AddonQtypeEssayComponent + ], + entryComponents: [ + AddonQtypeEssayComponent + ] +}) +export class AddonQtypeEssayModule { + constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeEssayHandler) { + questionDelegate.registerHandler(handler); + } +} diff --git a/src/addon/qtype/essay/providers/handler.ts b/src/addon/qtype/essay/providers/handler.ts new file mode 100644 index 000000000..ead384f00 --- /dev/null +++ b/src/addon/qtype/essay/providers/handler.ts @@ -0,0 +1,159 @@ + +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreQuestionHandler } from '@core/question/providers/delegate'; +import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; +import { AddonQtypeEssayComponent } from '../component/essay'; + +/** + * Handler to support essay question type. + */ +@Injectable() +export class AddonQtypeEssayHandler implements CoreQuestionHandler { + name = 'AddonQtypeEssay'; + type = 'qtype_essay'; + + protected div = document.createElement('div'); // A div element to search in HTML code. + + constructor(private utils: CoreUtilsProvider, private questionHelper: CoreQuestionHelperProvider, + private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider) { } + + /** + * Return the name of the behaviour to use for the question. + * If the question should use the default behaviour you shouldn't implement this function. + * + * @param {any} question The question. + * @param {string} behaviour The default behaviour. + * @return {string} The behaviour to use. + */ + getBehaviour(question: any, behaviour: string): string { + return 'manualgraded'; + } + + /** + * Return the Component to use to display the question. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} question The question to render. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector, question: any): any | Promise { + return AddonQtypeEssayComponent; + } + + /** + * Check if a question can be submitted. + * If a question cannot be submitted it should return a message explaining why (translated or not). + * + * @param {any} question The question. + * @return {string} Prevent submit message. Undefined or empty if can be submitted. + */ + getPreventSubmitMessage(question: any): string { + this.div.innerHTML = question.html; + + if (this.div.querySelector('div[id*=filemanager]')) { + // The question allows attachments. Since the app cannot attach files yet we will prevent submitting the question. + return 'core.question.errorattachmentsnotsupported'; + } + + if (this.questionHelper.hasDraftFileUrls(this.div.innerHTML)) { + return 'core.question.errorinlinefilesnotsupported'; + } + } + + /** + * Check if a response is complete. + * + * @param {any} question The question. + * @param {any} answers Object with the question answers (without prefix). + * @return {number} 1 if complete, 0 if not complete, -1 if cannot determine. + */ + isCompleteResponse(question: any, answers: any): number { + this.div.innerHTML = question.html; + + const hasInlineText = answers['answer'] && answers['answer'] !== '', + allowsAttachments = !!this.div.querySelector('div[id*=filemanager]'); + + if (!allowsAttachments) { + return hasInlineText ? 1 : 0; + } + + // We can't know if the attachments are required or if the user added any in web. + return -1; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Check if a student has provided enough of an answer for the question to be graded automatically, + * or whether it must be considered aborted. + * + * @param {any} question The question. + * @param {any} answers Object with the question answers (without prefix). + * @return {number} 1 if gradable, 0 if not gradable, -1 if cannot determine. + */ + isGradableResponse(question: any, answers: any): number { + return 0; + } + + /** + * Check if two responses are the same. + * + * @param {any} question Question. + * @param {any} prevAnswers Object with the previous question answers. + * @param {any} newAnswers Object with the new question answers. + * @return {boolean} Whether they're the same. + */ + isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer'); + } + + /** + * Prepare and add to answers the data to send to server based in the input. Return promise if async. + * + * @param {any} question Question. + * @param {any} answers The answers retrieved from the form. Prepared answers must be stored in this object. + * @param {boolean} [offline] Whether the data should be saved in offline. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} Return a promise resolved when done if async, void if sync. + */ + prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise { + this.div.innerHTML = question.html; + + // Search the textarea to get its name. + const textarea = this.div.querySelector('textarea[name*=_answer]'); + + if (textarea && typeof answers[textarea.name] != 'undefined') { + return this.domUtils.isRichTextEditorEnabled().then((enabled) => { + if (!enabled) { + // Rich text editor not enabled, add some HTML to the text if needed. + answers[textarea.name] = this.textUtils.formatHtmlLines(answers[textarea.name]); + } + }); + } + } +} diff --git a/src/addon/qtype/match/component/match.ts b/src/addon/qtype/match/component/match.ts index 117e59af5..9a2eb4ff6 100644 --- a/src/addon/qtype/match/component/match.ts +++ b/src/addon/qtype/match/component/match.ts @@ -12,10 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, Injector } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; -import { CoreDomUtilsProvider } from '@providers/utils/dom'; -import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component'; /** @@ -27,8 +25,8 @@ import { CoreQuestionBaseComponent } from '@core/question/classes/base-question- }) export class AddonQtypeMatchComponent extends CoreQuestionBaseComponent implements OnInit { - constructor(logger: CoreLoggerProvider, questionHelper: CoreQuestionHelperProvider, domUtils: CoreDomUtilsProvider) { - super(logger, 'AddonQtypeMatchComponent', questionHelper, domUtils); + constructor(logger: CoreLoggerProvider, injector: Injector) { + super(logger, 'AddonQtypeMatchComponent', injector); } /** diff --git a/src/addon/qtype/multichoice/component/multichoice.ts b/src/addon/qtype/multichoice/component/multichoice.ts index add6a7af7..395c4eabd 100644 --- a/src/addon/qtype/multichoice/component/multichoice.ts +++ b/src/addon/qtype/multichoice/component/multichoice.ts @@ -12,10 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, Injector } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; -import { CoreDomUtilsProvider } from '@providers/utils/dom'; -import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component'; /** @@ -27,8 +25,8 @@ import { CoreQuestionBaseComponent } from '@core/question/classes/base-question- }) export class AddonQtypeMultichoiceComponent extends CoreQuestionBaseComponent implements OnInit { - constructor(logger: CoreLoggerProvider, questionHelper: CoreQuestionHelperProvider, domUtils: CoreDomUtilsProvider) { - super(logger, 'AddonQtypeMultichoiceComponent', questionHelper, domUtils); + constructor(logger: CoreLoggerProvider, injector: Injector) { + super(logger, 'AddonQtypeMultichoiceComponent', injector); } /** diff --git a/src/addon/qtype/qtype.module.ts b/src/addon/qtype/qtype.module.ts index a3a7fd3a8..96df1cebb 100644 --- a/src/addon/qtype/qtype.module.ts +++ b/src/addon/qtype/qtype.module.ts @@ -17,6 +17,7 @@ import { AddonQtypeCalculatedModule } from './calculated/calculated.module'; import { AddonQtypeCalculatedMultiModule } from './calculatedmulti/calculatedmulti.module'; import { AddonQtypeCalculatedSimpleModule } from './calculatedsimple/calculatedsimple.module'; import { AddonQtypeDescriptionModule } from './description/description.module'; +import { AddonQtypeEssayModule } from './essay/essay.module'; import { AddonQtypeMatchModule } from './match/match.module'; import { AddonQtypeMultichoiceModule } from './multichoice/multichoice.module'; import { AddonQtypeNumericalModule } from './numerical/numerical.module'; @@ -31,6 +32,7 @@ import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module'; AddonQtypeCalculatedMultiModule, AddonQtypeCalculatedSimpleModule, AddonQtypeDescriptionModule, + AddonQtypeEssayModule, AddonQtypeMatchModule, AddonQtypeMultichoiceModule, AddonQtypeNumericalModule, diff --git a/src/addon/qtype/shortanswer/component/shortanswer.ts b/src/addon/qtype/shortanswer/component/shortanswer.ts index 77f2af456..152710d68 100644 --- a/src/addon/qtype/shortanswer/component/shortanswer.ts +++ b/src/addon/qtype/shortanswer/component/shortanswer.ts @@ -12,10 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, Injector } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; -import { CoreDomUtilsProvider } from '@providers/utils/dom'; -import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component'; /** @@ -27,8 +25,8 @@ import { CoreQuestionBaseComponent } from '@core/question/classes/base-question- }) export class AddonQtypeShortAnswerComponent extends CoreQuestionBaseComponent implements OnInit { - constructor(logger: CoreLoggerProvider, questionHelper: CoreQuestionHelperProvider, domUtils: CoreDomUtilsProvider) { - super(logger, 'AddonQtypeShortAnswerComponent', questionHelper, domUtils); + constructor(logger: CoreLoggerProvider, injector: Injector) { + super(logger, 'AddonQtypeShortAnswerComponent', injector); } /** diff --git a/src/core/question/classes/base-question-component.ts b/src/core/question/classes/base-question-component.ts index cce52c112..6daac231d 100644 --- a/src/core/question/classes/base-question-component.ts +++ b/src/core/question/classes/base-question-component.ts @@ -12,9 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Input, EventEmitter } from '@angular/core'; +import { Input, EventEmitter, Injector } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; /** @@ -30,10 +31,17 @@ export class CoreQuestionBaseComponent { @Input() onAbort: EventEmitter; // Should emit an event if the question should be aborted. protected logger; + protected questionHelper: CoreQuestionHelperProvider; + protected domUtils: CoreDomUtilsProvider; + protected textUtils: CoreTextUtilsProvider; - constructor(logger: CoreLoggerProvider, logName: string, protected questionHelper: CoreQuestionHelperProvider, - protected domUtils: CoreDomUtilsProvider) { + constructor(logger: CoreLoggerProvider, logName: string, protected injector: Injector) { this.logger = logger.getInstance(logName); + + // Use an injector to get the providers to prevent having to modify all subclasses if a new provider is needed. + this.questionHelper = injector.get(CoreQuestionHelperProvider); + this.domUtils = injector.get(CoreDomUtilsProvider); + this.textUtils = injector.get(CoreTextUtilsProvider); } /** @@ -180,6 +188,48 @@ export class CoreQuestionBaseComponent { return div; } + /** + * Initialize a question component of type essay. + * + * @return {void|HTMLElement} Element containing the question HTML, void if the data is not valid. + */ + initEssayComponent(): void | HTMLElement { + const questionDiv = this.initComponent(); + + if (questionDiv) { + // First search the textarea. + const textarea = questionDiv.querySelector('textarea[name*=_answer]'); + this.question.allowsAttachments = !!questionDiv.querySelector('div[id*=filemanager]'); + this.question.isMonospaced = !!questionDiv.querySelector('.qtype_essay_monospaced'); + this.question.isPlainText = this.question.isMonospaced || !!questionDiv.querySelector('.qtype_essay_plain'); + this.question.hasDraftFiles = this.questionHelper.hasDraftFileUrls(questionDiv.innerHTML); + + if (!textarea) { + // Textarea not found, we might be in review. Search the answer and the attachments. + this.question.answer = this.domUtils.getContentsOfElement(questionDiv, '.qtype_essay_response'); + this.question.attachments = this.questionHelper.getQuestionAttachmentsFromHtml( + this.domUtils.getContentsOfElement(questionDiv, '.attachments')); + } else { + // Textarea found. + const input = questionDiv.querySelector('input[type="hidden"][name*=answerformat]'), + content = textarea.innerHTML; + + this.question.textarea = { + id: textarea.id, + name: textarea.name, + text: content ? this.textUtils.decodeHTML(content) : '' + }; + + if (input) { + this.question.formatInput = { + name: input.name, + value: input.value + }; + } + } + } + } + /** * Initialize a question component that has an input of type "text". * diff --git a/src/core/question/providers/helper.ts b/src/core/question/providers/helper.ts index 5ffaf0c0e..61b67c5a8 100644 --- a/src/core/question/providers/helper.ts +++ b/src/core/question/providers/helper.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Injectable, EventEmitter } from '@angular/core'; +import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreQuestionProvider } from './question'; @@ -26,7 +27,7 @@ export class CoreQuestionHelperProvider { protected div = document.createElement('div'); // A div element to search in HTML code. constructor(private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider, - private questionProvider: CoreQuestionProvider) { } + private questionProvider: CoreQuestionProvider, private sitesProvider: CoreSitesProvider) { } /** * Add a behaviour button to the question's "behaviourButtons" property. @@ -266,6 +267,40 @@ export class CoreQuestionHelperProvider { } } + /** + * Given an HTML code with list of attachments, returns the list of attached files (filename and fileurl). + * Please take into account that this function will treat all the anchors in the HTML, you should provide + * an HTML containing only the attachments anchors. + * + * @param {String} html HTML code to search in. + * @return {Object[]} Attachments. + */ + getQuestionAttachmentsFromHtml(html: string): any[] { + this.div.innerHTML = html; + + // Remove the filemanager (area to attach files to a question). + this.domUtils.removeElement(this.div, 'div[id*=filemanager]'); + + // Search the anchors. + const anchors = Array.from(this.div.querySelectorAll('a')), + attachments = []; + + anchors.forEach((anchor) => { + let content = anchor.innerHTML; + + // Check anchor is valid. + if (anchor.href && content) { + content = this.textUtils.cleanTags(content, true).trim(); + attachments.push({ + filename: content, + fileurl: anchor.href + }); + } + }); + + return attachments; + } + /** * Get the sequence check from a question HTML. * @@ -299,6 +334,22 @@ export class CoreQuestionHelperProvider { return this.domUtils.getContentsOfElement(this.div, '.validationerror'); } + /** + * Check if some HTML contains draft file URLs for the current site. + * + * @param {string} html Question's HTML. + * @return {boolean} Whether it contains draft files URLs. + */ + hasDraftFileUrls(html: string): boolean { + let url = this.sitesProvider.getCurrentSite().getURL(); + if (url.slice(-1) != '/') { + url = url += '/'; + } + url += 'draftfile.php'; + + return html.indexOf(url) != -1; + } + /** * For each input element found in the HTML, search if there's a local answer stored and * override the HTML's value with the local one.