MOBILE-2272 quiz: Support viewing attachments and inline files

main
Dani Palou 2020-09-28 10:03:56 +02:00
parent b6ed831b25
commit 3f40127661
15 changed files with 227 additions and 39 deletions

View File

@ -1861,6 +1861,7 @@
"core.mainmenu.help": "moodle",
"core.mainmenu.logout": "moodle",
"core.mainmenu.website": "local_moodlemobileapp",
"core.maxfilesize": "moodle",
"core.maxsizeandattachments": "moodle",
"core.min": "moodle",
"core.mins": "moodle",
@ -1944,8 +1945,8 @@
"core.question.certainty": "qbehaviour_deferredcbm",
"core.question.complete": "question",
"core.question.correct": "question",
"core.question.errorattachmentsnotsupported": "local_moodlemobileapp",
"core.question.errorinlinefilesnotsupported": "local_moodlemobileapp",
"core.question.errorattachmentsnotsupportedinsite": "local_moodlemobileapp",
"core.question.errorembeddedfilesnotsupportedinsite": "local_moodlemobileapp",
"core.question.errorquestionnotsupported": "local_moodlemobileapp",
"core.question.feedback": "question",
"core.question.howtodraganddrop": "local_moodlemobileapp",

View File

@ -411,7 +411,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid
const params = this.urlUtils.extractUrlParams(response.data.reviewlesson.value);
if (params && params.pageid) {
// The retake can be reviewed, mark it as finished. Don't block the user for this.
this.setRetakeFinishedInSync(lessonId, retake.retake, params.pageid, siteId);
this.setRetakeFinishedInSync(lessonId, retake.retake, Number(params.pageid), siteId);
}
}
}

View File

@ -5,9 +5,10 @@
</ion-item>
<!-- Textarea. -->
<ion-item *ngIf="question.textarea && !question.hasDraftFiles">
<!-- "Format" hidden input -->
<ion-item *ngIf="question.textarea && (!question.hasDraftFiles || uploadFilesSupported)">
<!-- "Format" and draftid hidden inputs -->
<input item-content *ngIf="question.formatInput" type="hidden" [name]="question.formatInput.name" [value]="question.formatInput.value" >
<input item-content *ngIf="question.answerDraftIdInput" type="hidden" [name]="question.answerDraftIdInput.name" [value]="question.answerDraftIdInput.value" >
<!-- Plain text textarea. -->
<ion-textarea *ngIf="question.isPlainText" class="core-question-textarea" [ngClass]='{"core-monospaced": question.isMonospaced}' placeholder="{{ 'core.question.answer' | translate }}" [attr.name]="question.textarea.name" aria-multiline="true" [ngModel]="question.textarea.text"></ion-textarea>
<!-- Rich text editor. -->
@ -15,22 +16,29 @@
</ion-item>
<!-- Draft files not supported. -->
<ng-container *ngIf="question.textarea && question.hasDraftFiles">
<ng-container *ngIf="question.textarea && question.hasDraftFiles && !uploadFilesSupported">
<ion-item text-wrap class="core-danger-item">
<p class="core-question-warning">{{ 'core.question.errorinlinefilesnotsupported' | translate }}</p>
<p class="core-question-warning">{{ 'core.question.errorinlinefilesnotsupportedinsite' | translate }}</p>
</ion-item>
<ion-item text-wrap>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.textarea.text" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"></core-format-text></p>
</ion-item>
</ng-container>
<!-- Attachments not supported in the app yet. -->
<ion-item text-wrap *ngIf="question.allowsAttachments" class="core-danger-item">
<p class="core-question-warning">{{ 'core.question.errorattachmentsnotsupported' | translate }}</p>
</ion-item>
<!-- Attachments. -->
<ng-container *ngIf="question.allowsAttachments">
<core-attachments *ngIf="uploadFilesSupported" [files]="attachments" [component]="component" [componentId]="componentId" [maxSize]="question.attachmentsMaxBytes" [maxSubmissions]="question.attachmentsMaxFiles"></core-attachments>
<input item-content *ngIf="uploadFilesSupported" type="hidden" [name]="question.attachmentsDraftIdInput.name" [value]="question.attachmentsDraftIdInput.value" >
<!-- Attachments not supported in this site. -->
<ion-item text-wrap *ngIf="!uploadFilesSupported" class="core-danger-item">
<p class="core-question-warning">{{ 'core.question.errorattachmentsnotsupportedinsite' | translate }}</p>
</ion-item>
</ng-container>
<!-- Answer to the question and attachments (reviewing). -->
<ion-item text-wrap *ngIf="!question.textarea && (question.answer || (!question.attachments.length && !question.allowsAttachments))">
<ion-item text-wrap *ngIf="!question.textarea && (question.answer || question.answer == '')">
<p><core-format-text [ngClass]='{"core-monospaced": question.isMonospaced}' [component]="component" [componentId]="componentId" [text]="question.answer" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"></core-format-text></p>
</ion-item>

View File

@ -14,8 +14,11 @@
import { Component, OnInit, Injector } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreSites } from '@providers/sites';
import { CoreWSExternalFile } from '@providers/ws';
import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
import { FormControl, FormBuilder } from '@angular/forms';
import { CoreFileSession } from '@providers/file-session';
/**
* Component to render an essay question.
@ -28,6 +31,9 @@ export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implemen
protected formControl: FormControl;
attachments: CoreWSExternalFile[];
uploadFilesSupported: boolean;
constructor(logger: CoreLoggerProvider, injector: Injector, protected fb: FormBuilder) {
super(logger, 'AddonQtypeEssayComponent', injector);
}
@ -36,8 +42,14 @@ export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implemen
* Component being initialized.
*/
ngOnInit(): void {
this.uploadFilesSupported = CoreSites.instance.getCurrentSite().isVersionGreaterEqualThan('3.10');
this.initEssayComponent();
this.formControl = this.fb.control(this.question.textarea && this.question.textarea.text);
if (this.question.allowsAttachments && this.uploadFilesSupported) {
this.attachments = Array.from(this.questionHelper.getResponseFileAreaFiles(this.question, 'attachments'));
CoreFileSession.instance.setFiles(this.component, this.componentId + '_' + this.question.id, this.attachments);
}
}
}

View File

@ -14,6 +14,7 @@
// limitations under the License.
import { Injectable, Injector } from '@angular/core';
import { CoreSites } from '@providers/sites';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreUtilsProvider } from '@providers/utils/utils';
@ -68,11 +69,11 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
if (element.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';
return 'core.question.errorattachmentsnotsupportedinsite';
}
if (this.questionHelper.hasDraftFileUrls(element.innerHTML)) {
return 'core.question.errorinlinefilesnotsupported';
return 'core.question.errorinlinefilesnotsupportedinsite';
}
}
@ -139,13 +140,21 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
* @param siteId Site ID. If not defined, current site.
* @return Return a promise resolved when done if async, void if sync.
*/
prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<any> {
async prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): Promise<void> {
const element = this.domUtils.convertToElement(question.html);
// Search the textarea to get its name.
const textarea = <HTMLTextAreaElement> element.querySelector('textarea[name*=_answer]');
if (textarea && typeof answers[textarea.name] != 'undefined') {
if (this.questionHelper.hasDraftFileUrls(question.html) && question.responsefileareas) {
// Restore draftfile URLs.
const site = await CoreSites.instance.getSite(siteId);
answers[textarea.name] = this.textUtils.restoreDraftfileUrls(site.getURL(), answers[textarea.name],
question.html, this.questionHelper.getResponseFileAreaFiles(question, 'answer'));
}
// Add some HTML to the text if needed.
answers[textarea.name] = this.textUtils.formatHtmlLines(answers[textarea.name]);
}

View File

@ -1945,8 +1945,8 @@
"core.question.certainty": "Certainty",
"core.question.complete": "Complete",
"core.question.correct": "Correct",
"core.question.errorattachmentsnotsupported": "The application doesn't support attaching files to answers yet.",
"core.question.errorinlinefilesnotsupported": "The application doesn't support editing inline files yet.",
"core.question.errorattachmentsnotsupportedinsite": "Your site doesn't support attaching files to answers yet.",
"core.question.errorinlinefilesnotsupportedinsite": "Your site doesn't support editing inline files yet.",
"core.question.errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.",
"core.question.feedback": "Feedback",
"core.question.howtodraganddrop": "Tap to select then tap to drop.",

View File

@ -221,13 +221,16 @@ export class CoreSite {
// Versions of Moodle releases.
protected MOODLE_RELEASES = {
3.1: 2016052300,
3.2: 2016120500,
3.3: 2017051503,
3.4: 2017111300,
3.5: 2018051700,
3.6: 2018120300,
3.7: 2019052000
'3.1': 2016052300,
'3.2': 2016120500,
'3.3': 2017051503,
'3.4': 2017111300,
'3.5': 2018051700,
'3.6': 2018120300,
'3.7': 2019052000,
'3.8': 2019111800,
'3.9': 2020061500,
'3.10': 2020092400, // @todo: Replace with the right value once 3.10 is released.
};
static MINIMUM_MOODLE_VERSION = '3.1';

View File

@ -56,6 +56,7 @@ ion-app.app-root core-rich-text-editor {
img {
@include padding(null, null, null, 2px);
max-width: 95%;
width: auto;
}
&:empty:before {
content: attr(data-placeholder-text);

View File

@ -407,7 +407,7 @@ export class CoreGradesHelperProvider {
if (matches && matches.length) {
const hrefParams = this.urlUtils.extractUrlParams(matches[1]);
return hrefParams && hrefParams.id == moduleId;
return hrefParams && Number(hrefParams.id) == moduleId;
}
}

View File

@ -1152,7 +1152,7 @@ export class CoreLoginHelperProvider {
const providerToUse = identityProviders.find((provider) => {
const params = this.urlUtils.extractUrlParams(provider.url);
return params.id == currentSite.getOAuthId();
return Number(params.id) == currentSite.getOAuthId();
});
if (providerToUse) {

View File

@ -14,8 +14,10 @@
import { Input, Output, EventEmitter, Injector, ElementRef } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreSites } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreUrlUtils } from '@providers/utils/url';
import { CoreQuestionHelperProvider } from '@core/question/providers/helper';
/**
@ -162,6 +164,8 @@ export class CoreQuestionBaseComponent {
const input = questionEl.querySelector('input[type="text"][name*=answer]');
this.question.optionsFirst =
questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(radios[0].outerHTML);
return questionEl;
}
}
@ -201,27 +205,38 @@ export class CoreQuestionBaseComponent {
const questionEl = this.initComponent();
if (questionEl) {
// First search the textarea.
const textarea = <HTMLTextAreaElement> questionEl.querySelector('textarea[name*=_answer]');
const answerDraftIdInput = <HTMLInputElement> questionEl.querySelector('input[name*="_answer:itemid"]');
this.question.allowsAttachments = !!questionEl.querySelector('div[id*=filemanager]');
this.question.allowsAnswerFiles = !!answerDraftIdInput;
this.question.isMonospaced = !!questionEl.querySelector('.qtype_essay_monospaced');
this.question.isPlainText = this.question.isMonospaced || !!questionEl.querySelector('.qtype_essay_plain');
this.question.hasDraftFiles = this.questionHelper.hasDraftFileUrls(questionEl.innerHTML);
this.question.hasDraftFiles = this.question.allowsAnswerFiles &&
this.questionHelper.hasDraftFileUrls(questionEl.innerHTML);
if (!textarea) {
// Textarea not found, we might be in review. Search the answer and the attachments.
if (!textarea && !this.question.allowsAttachments) {
// Textarea and filemanager not found, we might be in review. Search the answer and the attachments.
this.question.answer = this.domUtils.getContentsOfElement(questionEl, '.qtype_essay_response');
this.question.attachments = this.questionHelper.getQuestionAttachmentsFromHtml(
this.domUtils.getContentsOfElement(questionEl, '.attachments'));
} else {
// Textarea found.
const input = <HTMLInputElement> questionEl.querySelector('input[type="hidden"][name*=answerformat]'),
content = textarea.innerHTML;
return questionEl;
}
if (textarea) {
const input = <HTMLInputElement> questionEl.querySelector('input[type="hidden"][name*=answerformat]');
let content = this.textUtils.decodeHTML(textarea.innerHTML || '');
if (this.question.hasDraftFiles && this.question.responsefileareas) {
content = this.textUtils.replaceDraftfileUrls(CoreSites.instance.getCurrentSite().getURL(), content,
this.questionHelper.getResponseFileAreaFiles(this.question, 'answer')).text;
}
this.question.textarea = {
id: textarea.id,
name: textarea.name,
text: content ? this.textUtils.decodeHTML(content) : ''
text: content,
};
if (input) {
@ -231,6 +246,38 @@ export class CoreQuestionBaseComponent {
};
}
}
if (answerDraftIdInput) {
this.question.answerDraftIdInput = {
name: answerDraftIdInput.name,
value: Number(answerDraftIdInput.value),
};
}
if (this.question.allowsAttachments) {
const attachmentsInput = <HTMLInputElement> questionEl.querySelector('.attachments input[name*=_attachments]');
const objectElement = <HTMLObjectElement> questionEl.querySelector('.attachments object');
const fileManagerUrl = objectElement && objectElement.data;
if (attachmentsInput) {
this.question.attachmentsDraftIdInput = {
name: attachmentsInput.name,
value: Number(attachmentsInput.value),
};
}
if (fileManagerUrl) {
const params = CoreUrlUtils.instance.extractUrlParams(fileManagerUrl);
const maxBytes = Number(params.maxbytes);
const areaMaxBytes = Number(params.areamaxbytes);
this.question.attachmentsMaxFiles = Number(params.maxfiles);
this.question.attachmentsMaxBytes = maxBytes === -1 || areaMaxBytes === -1 ?
Math.max(maxBytes, areaMaxBytes) : Math.min(maxBytes, areaMaxBytes);
}
}
return questionEl;
}
}
@ -270,6 +317,8 @@ export class CoreQuestionBaseComponent {
// Set the question text.
this.question.text = content.innerHTML;
return element;
}
/**

View File

@ -5,8 +5,8 @@
"certainty": "Certainty",
"complete": "Complete",
"correct": "Correct",
"errorattachmentsnotsupported": "The application doesn't support attaching files to answers yet.",
"errorinlinefilesnotsupported": "The application doesn't support editing inline files yet.",
"errorattachmentsnotsupportedinsite": "Your site doesn't support attaching files to answers yet.",
"errorinlinefilesnotsupportedinsite": "Your site doesn't support editing inline files yet.",
"errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.",
"feedback": "Feedback",
"howtodraganddrop": "Tap to select then tap to drop.",

View File

@ -16,6 +16,7 @@ import { Injectable, EventEmitter } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { CoreFilepoolProvider } from '@providers/filepool';
import { CoreSitesProvider } from '@providers/sites';
import { CoreWSExternalFile } from '@providers/ws';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreUrlUtilsProvider } from '@providers/utils/url';
@ -421,6 +422,25 @@ export class CoreQuestionHelperProvider {
return state ? state.class : '';
}
/**
* Return the files of a certain response file area.
*
* @param question Question.
* @param areaName Name of the area, e.g. 'attachments'.
* @return List of files.
*/
getResponseFileAreaFiles(question: any, areaName: string): CoreWSExternalFile[] {
if (!question.responsefileareas) {
return [];
}
const area = question.responsefileareas.find((area) => {
return area.area == areaName;
});
return area && area.files || [];
}
/**
* Get the validation error message from a question HTML if it's there.
*

View File

@ -19,6 +19,7 @@ import { TranslateService } from '@ngx-translate/core';
import { CoreLangProvider } from '../lang';
import { makeSingleton } from '@singletons/core.singletons';
import { CoreApp } from '../app';
import { CoreWSExternalFile } from '../ws';
/**
* Different type of errors the app can treat.
@ -699,6 +700,60 @@ export class CoreTextUtilsProvider {
return text.replace(/(?:\r\n|\r|\n)/g, newValue);
}
/**
* Replace draftfile URLs with the equivalent pluginfile URL.
*
* @param siteUrl URL of the site.
* @param text Text to treat, including draftfile URLs.
* @param files List of files of the area, using pluginfile URLs.
* @return Treated text and map with the replacements.
*/
replaceDraftfileUrls(siteUrl: string, text: string, files: CoreWSExternalFile[])
: {text: string, replaceMap?: {[url: string]: string}} {
if (!text || !files || !files.length) {
return {text};
}
const draftfileUrl = this.concatenatePaths(siteUrl, 'draftfile.php');
const matches = text.match(new RegExp(this.escapeForRegex(draftfileUrl) + '[^\'" ]+', 'ig'));
if (!matches || !matches.length) {
return {text};
}
// Index the pluginfile URLs by file name.
const pluginfileMap: {[name: string]: string} = {};
files.forEach((file) => {
pluginfileMap[file.filename] = file.fileurl;
});
// Replace each draftfile with the corresponding pluginfile URL.
const replaceMap: {[url: string]: string} = {};
matches.forEach((url) => {
if (replaceMap[url]) {
// URL already treated, same file embedded more than once.
return;
}
// Get the filename from the URL.
let filename = url.substr(url.lastIndexOf('/') + 1);
if (filename.indexOf('?') != -1) {
filename = filename.substr(0, filename.indexOf('?'));
}
if (pluginfileMap[filename]) {
replaceMap[url] = pluginfileMap[filename];
text = text.replace(new RegExp(this.escapeForRegex(url), 'g'), pluginfileMap[filename]);
}
});
return {
text,
replaceMap,
};
}
/**
* Replace @@PLUGINFILE@@ wildcards with the real URL in a text.
*
@ -717,6 +772,36 @@ export class CoreTextUtilsProvider {
return text;
}
/**
* Restore original draftfile URLs.
*
* @param text Text to treat, including pluginfile URLs.
* @param replaceMap Map of the replacements that were done.
* @return Treated text.
*/
restoreDraftfileUrls(siteUrl: string, treatedText: string, originalText: string, files: CoreWSExternalFile[]): string {
if (!treatedText || !files || !files.length) {
return treatedText;
}
const draftfileUrl = this.concatenatePaths(siteUrl, 'draftfile.php');
const draftfileUrlRegexPrefix = this.escapeForRegex(draftfileUrl) + '/[^/]+/[^/]+/[^/]+/[^/]+/';
files.forEach((file) => {
// Search the draftfile URL in the original text.
const matches = originalText.match(new RegExp(
draftfileUrlRegexPrefix + this.escapeForRegex(file.filename) + '[^\'" ]*', 'i'));
if (!matches || !matches[0]) {
return; // Original URL not found, skip.
}
treatedText = treatedText.replace(new RegExp(this.escapeForRegex(file.fileurl), 'g'), matches[0]);
});
return treatedText;
}
/**
* Replace pluginfile URLs with @@PLUGINFILE@@ wildcards.
*

View File

@ -114,10 +114,10 @@ export class CoreUrlUtilsProvider {
* @param url URL to treat.
* @return Object with the params.
*/
extractUrlParams(url: string): any {
extractUrlParams(url: string): {[name: string]: string} {
const regex = /[?&]+([^=&]+)=?([^&]*)?/gi,
subParamsPlaceholder = '@@@SUBPARAMS@@@',
params: any = {},
params: {[name: string]: string} = {},
urlAndHash = url.split('#'),
questionMarkSplit = urlAndHash[0].split('?');
let subParams;