Skip to content

Commit d2f4c85

Browse files
committed
MOBILE-2272 quiz: Support add new files in essay in offline
1 parent 9d0ae3c commit d2f4c85

File tree

8 files changed

+337
-65
lines changed

8 files changed

+337
-65
lines changed

src/addon/mod/quiz/providers/quiz-sync.ts

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -78,25 +78,33 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
7878
* @param quiz Quiz.
7979
* @param courseId Course ID.
8080
* @param warnings List of warnings generated by the sync.
81-
* @param attemptId Last attempt ID.
82-
* @param offlineAttempt Offline attempt synchronized, if any.
83-
* @param onlineAttempt Online data for the offline attempt.
84-
* @param removeAttempt Whether the offline data should be removed.
85-
* @param updated Whether some data was sent to the site.
81+
* @param options Other options.
8682
* @return Promise resolved on success.
8783
*/
88-
protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], attemptId?: number, offlineAttempt?: any,
89-
onlineAttempt?: any, removeAttempt?: boolean, updated?: boolean): Promise<AddonModQuizSyncResult> {
84+
protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], options?: FinishSyncOptions)
85+
: Promise<AddonModQuizSyncResult> {
86+
options = options || {};
9087

9188
// Invalidate the data for the quiz and attempt.
92-
return this.quizProvider.invalidateAllQuizData(quiz.id, courseId, attemptId, siteId).catch(() => {
89+
return this.quizProvider.invalidateAllQuizData(quiz.id, courseId, options.attemptId, siteId).catch(() => {
9390
// Ignore errors.
9491
}).then(() => {
95-
if (removeAttempt && attemptId) {
96-
return this.quizOfflineProvider.removeAttemptAndAnswers(attemptId, siteId);
92+
if (options.removeAttempt && options.attemptId) {
93+
const promises = [];
94+
95+
promises.push(this.quizOfflineProvider.removeAttemptAndAnswers(options.attemptId, siteId));
96+
97+
if (options.onlineQuestions) {
98+
for (const slot in options.onlineQuestions) {
99+
promises.push(this.questionDelegate.deleteOfflineData(options.onlineQuestions[slot],
100+
AddonModQuizProvider.COMPONENT, quiz.coursemodule, siteId));
101+
}
102+
}
103+
104+
return Promise.all(promises);
97105
}
98106
}).then(() => {
99-
if (updated) {
107+
if (options.updated) {
100108
// Data has been sent. Update prefetched data.
101109
return this.courseProvider.getModuleBasicInfoByInstance(quiz.id, 'quiz', siteId).then((module) => {
102110
return this.prefetchAfterUpdateQuiz(module, quiz, courseId, undefined, siteId);
@@ -110,14 +118,14 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
110118
});
111119
}).then(() => {
112120
// Check if online attempt was finished because of the sync.
113-
if (onlineAttempt && !this.quizProvider.isAttemptFinished(onlineAttempt.state)) {
121+
if (options.onlineAttempt && !this.quizProvider.isAttemptFinished(options.onlineAttempt.state)) {
114122
// Attempt wasn't finished at start. Check if it's finished now.
115123
return this.quizProvider.getUserAttempts(quiz.id, {cmId: quiz.coursemodule, siteId}).then((attempts) => {
116124
// Search the attempt.
117125
for (const i in attempts) {
118126
const attempt = attempts[i];
119127

120-
if (attempt.id == onlineAttempt.id) {
128+
if (attempt.id == options.onlineAttempt.id) {
121129
return this.quizProvider.isAttemptFinished(attempt.state);
122130
}
123131
}
@@ -355,15 +363,25 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
355363
// Attempt not found or it's finished in online. Discard it.
356364
warnings.push(this.translate.instant('addon.mod_quiz.warningattemptfinished'));
357365

358-
return this.finishSync(siteId, quiz, courseId, warnings, offlineAttempt.id, offlineAttempt, onlineAttempt, true);
366+
return this.finishSync(siteId, quiz, courseId, warnings, {
367+
attemptId: offlineAttempt.id,
368+
offlineAttempt,
369+
onlineAttempt,
370+
removeAttempt: true,
371+
});
359372
}
360373

361374
// Get the data stored in offline.
362375
const answersList = await this.quizOfflineProvider.getAttemptAnswers(offlineAttempt.id, siteId);
363376

364377
if (!answersList.length) {
365378
// No answers stored, finish.
366-
return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt, true);
379+
return this.finishSync(siteId, quiz, courseId, warnings, {
380+
attemptId: lastAttemptId,
381+
offlineAttempt,
382+
onlineAttempt,
383+
removeAttempt: true,
384+
});
367385
}
368386

369387
const offlineAnswers = this.questionProvider.convertAnswersArrayToObject(answersList);
@@ -376,17 +394,23 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
376394
'core.settings.synchronization', siteId);
377395

378396
// Now get the online questions data.
379-
const pages = this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions);
380-
381397
const onlineQuestions = await this.quizProvider.getAllQuestionsData(quiz, onlineAttempt, preflightData, {
382-
pages,
398+
pages: this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions),
383399
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
384400
siteId,
385401
});
386402

387403
// Validate questions, discarding the offline answers that can't be synchronized.
388404
const discardedData = await this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId);
389405

406+
// Let questions prepare the data to send.
407+
await Promise.all(Object.keys(offlineQuestions).map(async (slot) => {
408+
const onlineQuestion = onlineQuestions[slot];
409+
410+
await this.questionDelegate.prepareSyncData(onlineQuestion, offlineQuestions[slot].answers,
411+
AddonModQuizProvider.COMPONENT, quiz.coursemodule, siteId);
412+
}));
413+
390414
// Get the answers to send.
391415
const answers = this.quizOfflineProvider.extractAnswersFromQuestions(offlineQuestions);
392416
const finish = offlineAttempt.finished && !discardedData;
@@ -410,7 +434,14 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
410434
}
411435

412436
// Data sent. Finish the sync.
413-
return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt, true, true);
437+
return this.finishSync(siteId, quiz, courseId, warnings, {
438+
attemptId: lastAttemptId,
439+
offlineAttempt,
440+
onlineAttempt,
441+
removeAttempt: true,
442+
updated: true,
443+
onlineQuestions,
444+
});
414445
}
415446

416447
/**
@@ -458,3 +489,15 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
458489
});
459490
}
460491
}
492+
493+
/**
494+
* Options to pass to finish sync.
495+
*/
496+
type FinishSyncOptions = {
497+
attemptId?: number; // Last attempt ID.
498+
offlineAttempt?: any; // Offline attempt synchronized, if any.
499+
onlineAttempt?: any; // Online data for the offline attempt.
500+
removeAttempt?: boolean; // Whether the offline data should be removed.
501+
updated?: boolean; // Whether the offline data should be removed.
502+
onlineQuestions?: any; // Online questions indexed by slot.
503+
};

src/addon/qtype/essay/component/addon-qtype-essay.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
<!-- Attachments. -->
2929
<ng-container *ngIf="question.allowsAttachments">
30-
<core-attachments *ngIf="uploadFilesSupported" [files]="attachments" [component]="component" [componentId]="componentId" [maxSize]="question.attachmentsMaxBytes" [maxSubmissions]="question.attachmentsMaxFiles" [allowOffline]="offline"></core-attachments>
30+
<core-attachments *ngIf="uploadFilesSupported" [files]="attachments" [component]="component" [componentId]="componentId" [maxSize]="question.attachmentsMaxBytes" [maxSubmissions]="question.attachmentsMaxFiles" [allowOffline]="offlineEnabled"></core-attachments>
3131

3232
<input item-content *ngIf="uploadFilesSupported" type="hidden" [name]="question.attachmentsDraftIdInput.name" [value]="question.attachmentsDraftIdInput.value" >
3333

src/addon/qtype/essay/component/essay.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import { Component, OnInit, Injector } from '@angular/core';
1616
import { CoreLoggerProvider } from '@providers/logger';
1717
import { CoreWSExternalFile } from '@providers/ws';
18+
import { CoreFileUploader } from '@core/fileuploader/providers/fileuploader';
1819
import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
1920
import { CoreQuestion } from '@core/question/providers/question';
2021
import { FormControl, FormBuilder } from '@angular/forms';
@@ -48,9 +49,33 @@ export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implemen
4849
this.formControl = this.fb.control(this.question.textarea && this.question.textarea.text);
4950

5051
if (this.question.allowsAttachments && this.uploadFilesSupported) {
52+
this.loadAttachments();
53+
}
54+
}
55+
56+
/**
57+
* Load attachments.
58+
*
59+
* @return Promise resolved when done.
60+
*/
61+
async loadAttachments(): Promise<void> {
62+
if (this.offlineEnabled && this.question.localAnswers['attachments_offline']) {
63+
64+
const attachmentsData = this.textUtils.parseJSON(this.question.localAnswers['attachments_offline'], {});
65+
let offlineFiles = [];
66+
67+
if (attachmentsData.offline) {
68+
offlineFiles = await this.questionHelper.getStoredQuestionFiles(this.question, this.component, this.componentId);
69+
70+
offlineFiles = CoreFileUploader.instance.markOfflineFiles(offlineFiles);
71+
}
72+
73+
this.attachments = (attachmentsData.online || []).concat(offlineFiles);
74+
} else {
5175
this.attachments = Array.from(this.questionHelper.getResponseFileAreaFiles(this.question, 'attachments'));
52-
CoreFileSession.instance.setFiles(this.component,
53-
CoreQuestion.instance.getQuestionComponentId(this.question, this.componentId), this.attachments);
5476
}
77+
78+
CoreFileSession.instance.setFiles(this.component,
79+
CoreQuestion.instance.getQuestionComponentId(this.question, this.componentId), this.attachments);
5580
}
5681
}

src/addon/qtype/essay/providers/handler.ts

Lines changed: 106 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
5454
CoreFileUploader.instance.clearTmpFiles(files);
5555
}
5656

57+
/**
58+
* Delete any stored data for the question.
59+
*
60+
* @param question Question.
61+
* @param component The component the question is related to.
62+
* @param componentId Component ID.
63+
* @param siteId Site ID. If not defined, current site.
64+
* @return Promise resolved when done.
65+
*/
66+
deleteOfflineData(question: any, component: string, componentId: string | number, siteId?: string): Promise<void> {
67+
return this.questionHelper.deleteStoredQuestionFiles(question, component, componentId, siteId);
68+
}
69+
5770
/**
5871
* Return the name of the behaviour to use for the question.
5972
* If the question should use the default behaviour you shouldn't implement this function.
@@ -186,11 +199,11 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
186199
const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
187200
const originalAttachments = this.questionHelper.getResponseFileAreaFiles(question, 'attachments');
188201

189-
return CoreFileUploader.instance.areFileListDifferent(attachments, originalAttachments);
202+
return !CoreFileUploader.instance.areFileListDifferent(attachments, originalAttachments);
190203
}
191204

192205
/**
193-
* Prepare and add to answers the data to send to server based in the input. Return promise if async.
206+
* Prepare and add to answers the data to send to server based in the input.
194207
*
195208
* @param question Question.
196209
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
@@ -210,29 +223,103 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
210223
const textarea = <HTMLTextAreaElement> element.querySelector('textarea[name*=_answer]');
211224

212225
if (textarea && typeof answers[textarea.name] != 'undefined') {
213-
if (this.questionHelper.hasDraftFileUrls(question.html) && question.responsefileareas) {
214-
// Restore draftfile URLs.
215-
const site = await CoreSites.instance.getSite(siteId);
226+
await this.prepareTextAnswer(question, answers, textarea, siteId);
227+
}
216228

217-
answers[textarea.name] = this.textUtils.restoreDraftfileUrls(site.getURL(), answers[textarea.name],
218-
question.html, this.questionHelper.getResponseFileAreaFiles(question, 'answer'));
219-
}
229+
if (attachmentsInput) {
230+
await this.prepareAttachments(question, answers, offline, component, componentId, attachmentsInput, siteId);
231+
}
232+
}
233+
234+
/**
235+
* Prepare attachments.
236+
*
237+
* @param question Question.
238+
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
239+
* @param offline Whether the data should be saved in offline.
240+
* @param component The component the question is related to.
241+
* @param componentId Component ID.
242+
* @param attachmentsInput The HTML input containing the draft ID for attachments.
243+
* @param siteId Site ID. If not defined, current site.
244+
* @return Return a promise resolved when done if async, void if sync.
245+
*/
246+
async prepareAttachments(question: any, answers: any, offline: boolean, component: string, componentId: string | number,
247+
attachmentsInput: HTMLInputElement, siteId?: string): Promise<void> {
248+
249+
// Treat attachments if any.
250+
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
251+
const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
252+
const draftId = Number(attachmentsInput.value);
253+
254+
if (offline) {
255+
// Get the folder where to store the files.
256+
const folderPath = CoreQuestion.instance.getQuestionFolder(question.type, component, questionComponentId, siteId);
257+
258+
const result = await CoreFileUploader.instance.storeFilesToUpload(folderPath, attachments);
220259

221-
// Add some HTML to the text if needed.
222-
answers[textarea.name] = this.textUtils.formatHtmlLines(answers[textarea.name]);
260+
// Store the files in the answers.
261+
answers[attachmentsInput.name + '_offline'] = JSON.stringify(result);
262+
} else {
263+
await CoreFileUploader.instance.uploadFiles(draftId, attachments, siteId);
223264
}
265+
}
266+
267+
/**
268+
* Prepare data to send when performing a synchronization.
269+
*
270+
* @param question Question.
271+
* @param answers Answers of the question, without the prefix.
272+
* @param component The component the question is related to.
273+
* @param componentId Component ID.
274+
* @param siteId Site ID. If not defined, current site.
275+
* @return Promise resolved when done.
276+
*/
277+
async prepareSyncData(question: any, answers: {[name: string]: any}, component: string, componentId: string | number,
278+
siteId?: string): Promise<void> {
279+
280+
const element = this.domUtils.convertToElement(question.html);
281+
const attachmentsInput = <HTMLInputElement> element.querySelector('.attachments input[name*=_attachments]');
224282

225283
if (attachmentsInput) {
226-
// Treat attachments if any.
227-
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
228-
const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
229-
const draftId = Number(attachmentsInput.value);
230-
231-
if (offline) {
232-
// @TODO Support offline.
233-
} else {
234-
await CoreFileUploader.instance.uploadFiles(draftId, attachments, siteId);
284+
// Update the draft ID, the stored one could no longer be valid.
285+
answers.attachments = attachmentsInput.value;
286+
}
287+
288+
if (answers && answers.attachments_offline) {
289+
// Check if it has new attachments to upload.
290+
const attachmentsData = this.textUtils.parseJSON(answers.attachments_offline, {});
291+
292+
if (attachmentsData.offline) {
293+
// Upload the offline files.
294+
const offlineFiles = await this.questionHelper.getStoredQuestionFiles(question, component, componentId, siteId);
295+
296+
await CoreFileUploader.instance.uploadFiles(answers.attachments, attachmentsData.online.concat(offlineFiles),
297+
siteId);
235298
}
299+
300+
delete answers.attachments_offline;
301+
}
302+
}
303+
304+
/**
305+
* Prepare the text answer.
306+
*
307+
* @param question Question.
308+
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
309+
* @param textarea The textarea HTML element of the question.
310+
* @param siteId Site ID. If not defined, current site.
311+
* @return Promise resolved when done.
312+
*/
313+
async prepareTextAnswer(question: any, answers: any, textarea: HTMLTextAreaElement, siteId?: string): Promise<void> {
314+
if (this.questionHelper.hasDraftFileUrls(question.html) && question.responsefileareas) {
315+
// Restore draftfile URLs.
316+
const site = await CoreSites.instance.getSite(siteId);
317+
318+
answers[textarea.name] = this.textUtils.restoreDraftfileUrls(site.getURL(), answers[textarea.name],
319+
question.html, this.questionHelper.getResponseFileAreaFiles(question, 'answer'));
236320
}
321+
322+
// Add some HTML to the text if needed.
323+
answers[textarea.name] = this.textUtils.formatHtmlLines(answers[textarea.name]);
237324
}
238325
}

src/core/fileuploader/providers/fileuploader.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -552,16 +552,26 @@ export class CoreFileUploaderProvider {
552552
return;
553553
}
554554

555-
await Promise.all(files.map(async (file) => {
556-
if ((<CoreWSExternalFile> file).filename && !(<FileEntry> file).name) {
557-
// File already uploaded, ignore it.
558-
return;
555+
// Index the online files by name.
556+
const usedNames: {[name: string]: (CoreWSExternalFile | FileEntry)} = {};
557+
const filesToUpload: FileEntry[] = [];
558+
files.forEach((file) => {
559+
const isOnlineFile = (<CoreWSExternalFile> file).filename && !(<FileEntry> file).name;
560+
561+
if (isOnlineFile) {
562+
usedNames[(<CoreWSExternalFile> file).filename.toLowerCase()] = file;
563+
} else {
564+
filesToUpload.push(<FileEntry> file);
559565
}
566+
});
560567

561-
file = <FileEntry> file;
568+
await Promise.all(filesToUpload.map(async (file) => {
569+
// Make sure the file name is unique in the area.
570+
const name = this.fileProvider.calculateUniqueName(usedNames, file.name);
571+
usedNames[name] = file;
562572

563573
// Now upload the file.
564-
const options = this.getFileUploadOptions(file.toURL(), file.name, undefined, false, 'draft', itemId);
574+
const options = this.getFileUploadOptions(file.toURL(), name, undefined, false, 'draft', itemId);
565575

566576
await this.uploadFile(file.toURL(), options, undefined, siteId);
567577
}));

0 commit comments

Comments
 (0)