commit
0cf2b3c74e
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -569,7 +569,8 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy {
|
|||
|
||||
// Prepare the answers to be sent for the attempt.
|
||||
protected prepareAnswers(): Promise<any> {
|
||||
return this.questionHelper.prepareAnswers(this.questions, this.getAnswers(), this.offline);
|
||||
return this.questionHelper.prepareAnswers(this.questions, this.getAnswers(), this.offline, this.component,
|
||||
this.quiz.coursemodule);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -612,6 +613,8 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy {
|
|||
if (this.formElement) {
|
||||
this.domUtils.triggerFormSubmittedEvent(this.formElement, !this.offline, this.sitesProvider.getCurrentSiteId());
|
||||
}
|
||||
|
||||
return this.questionHelper.clearTmpData(this.questions, this.component, this.quiz.coursemodule);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -342,8 +342,8 @@ export class AddonModQuizOfflineProvider {
|
|||
for (const slot in questionsWithAnswers) {
|
||||
const question = questionsWithAnswers[slot];
|
||||
|
||||
promises.push(this.behaviourDelegate.determineNewState(
|
||||
quiz.preferredbehaviour, AddonModQuizProvider.COMPONENT, attempt.id, question, siteId).then((state) => {
|
||||
promises.push(this.behaviourDelegate.determineNewState(quiz.preferredbehaviour, AddonModQuizProvider.COMPONENT,
|
||||
attempt.id, question, quiz.coursemodule, siteId).then((state) => {
|
||||
// Check if state has changed.
|
||||
if (state && state.name != question.state) {
|
||||
newStates[question.slot] = state.name;
|
||||
|
|
|
@ -21,6 +21,7 @@ import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites';
|
|||
import { CoreSyncProvider } from '@providers/sync';
|
||||
import { CoreTextUtilsProvider } from '@providers/utils/text';
|
||||
import { CoreTimeUtilsProvider } from '@providers/utils/time';
|
||||
import { CoreUtils } from '@providers/utils/utils';
|
||||
import { CoreCourseProvider } from '@core/course/providers/course';
|
||||
import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper';
|
||||
import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
|
||||
|
@ -77,25 +78,33 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
|
|||
* @param quiz Quiz.
|
||||
* @param courseId Course ID.
|
||||
* @param warnings List of warnings generated by the sync.
|
||||
* @param attemptId Last attempt ID.
|
||||
* @param offlineAttempt Offline attempt synchronized, if any.
|
||||
* @param onlineAttempt Online data for the offline attempt.
|
||||
* @param removeAttempt Whether the offline data should be removed.
|
||||
* @param updated Whether some data was sent to the site.
|
||||
* @param options Other options.
|
||||
* @return Promise resolved on success.
|
||||
*/
|
||||
protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], attemptId?: number, offlineAttempt?: any,
|
||||
onlineAttempt?: any, removeAttempt?: boolean, updated?: boolean): Promise<AddonModQuizSyncResult> {
|
||||
protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], options?: FinishSyncOptions)
|
||||
: Promise<AddonModQuizSyncResult> {
|
||||
options = options || {};
|
||||
|
||||
// Invalidate the data for the quiz and attempt.
|
||||
return this.quizProvider.invalidateAllQuizData(quiz.id, courseId, attemptId, siteId).catch(() => {
|
||||
return this.quizProvider.invalidateAllQuizData(quiz.id, courseId, options.attemptId, siteId).catch(() => {
|
||||
// Ignore errors.
|
||||
}).then(() => {
|
||||
if (removeAttempt && attemptId) {
|
||||
return this.quizOfflineProvider.removeAttemptAndAnswers(attemptId, siteId);
|
||||
if (options.removeAttempt && options.attemptId) {
|
||||
const promises = [];
|
||||
|
||||
promises.push(this.quizOfflineProvider.removeAttemptAndAnswers(options.attemptId, siteId));
|
||||
|
||||
if (options.onlineQuestions) {
|
||||
for (const slot in options.onlineQuestions) {
|
||||
promises.push(this.questionDelegate.deleteOfflineData(options.onlineQuestions[slot],
|
||||
AddonModQuizProvider.COMPONENT, quiz.coursemodule, siteId));
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
}).then(() => {
|
||||
if (updated) {
|
||||
if (options.updated) {
|
||||
// Data has been sent. Update prefetched data.
|
||||
return this.courseProvider.getModuleBasicInfoByInstance(quiz.id, 'quiz', siteId).then((module) => {
|
||||
return this.prefetchAfterUpdateQuiz(module, quiz, courseId, undefined, siteId);
|
||||
|
@ -109,14 +118,14 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
|
|||
});
|
||||
}).then(() => {
|
||||
// Check if online attempt was finished because of the sync.
|
||||
if (onlineAttempt && !this.quizProvider.isAttemptFinished(onlineAttempt.state)) {
|
||||
if (options.onlineAttempt && !this.quizProvider.isAttemptFinished(options.onlineAttempt.state)) {
|
||||
// Attempt wasn't finished at start. Check if it's finished now.
|
||||
return this.quizProvider.getUserAttempts(quiz.id, {cmId: quiz.coursemodule, siteId}).then((attempts) => {
|
||||
// Search the attempt.
|
||||
for (const i in attempts) {
|
||||
const attempt = attempts[i];
|
||||
|
||||
if (attempt.id == onlineAttempt.id) {
|
||||
if (attempt.id == options.onlineAttempt.id) {
|
||||
return this.quizProvider.isAttemptFinished(attempt.state);
|
||||
}
|
||||
}
|
||||
|
@ -288,16 +297,6 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
|
|||
syncQuiz(quiz: any, askPreflight?: boolean, siteId?: string): Promise<AddonModQuizSyncResult> {
|
||||
siteId = siteId || this.sitesProvider.getCurrentSiteId();
|
||||
|
||||
const warnings = [];
|
||||
const courseId = quiz.course;
|
||||
const modOptions = {
|
||||
cmId: quiz.coursemodule,
|
||||
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
||||
siteId,
|
||||
};
|
||||
let syncPromise;
|
||||
let preflightData;
|
||||
|
||||
if (this.isSyncing(quiz.id, siteId)) {
|
||||
// There's already a sync ongoing for this quiz, return the promise.
|
||||
return this.getOngoingSync(quiz.id, siteId);
|
||||
|
@ -310,115 +309,139 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
|
|||
return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate}));
|
||||
}
|
||||
|
||||
return this.addOngoingSync(quiz.id, this.performSyncQuiz(quiz, askPreflight, siteId), siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the quiz sync.
|
||||
*
|
||||
* @param quiz Quiz.
|
||||
* @param askPreflight Whether we should ask for preflight data if needed.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved in success.
|
||||
*/
|
||||
async performSyncQuiz(quiz: any, askPreflight?: boolean, siteId?: string): Promise<AddonModQuizSyncResult> {
|
||||
siteId = siteId || this.sitesProvider.getCurrentSiteId();
|
||||
|
||||
const warnings = [];
|
||||
const courseId = quiz.course;
|
||||
const modOptions = {
|
||||
cmId: quiz.coursemodule,
|
||||
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
||||
siteId,
|
||||
};
|
||||
|
||||
this.logger.debug('Try to sync quiz ' + quiz.id + ' in site ' + siteId);
|
||||
|
||||
// Sync offline logs.
|
||||
syncPromise = this.logHelper.syncIfNeeded(AddonModQuizProvider.COMPONENT, quiz.id, siteId).catch(() => {
|
||||
// Ignore errors.
|
||||
}).then(() => {
|
||||
// Get all the offline attempts for the quiz.
|
||||
return this.quizOfflineProvider.getQuizAttempts(quiz.id, siteId);
|
||||
}).then((attempts) => {
|
||||
// Should return 0 or 1 attempt.
|
||||
if (!attempts.length) {
|
||||
return this.finishSync(siteId, quiz, courseId, warnings);
|
||||
}
|
||||
await CoreUtils.instance.ignoreErrors(this.logHelper.syncIfNeeded(AddonModQuizProvider.COMPONENT, quiz.id, siteId));
|
||||
|
||||
const offlineAttempt = attempts.pop();
|
||||
// Get all the offline attempts for the quiz. It should always be 0 or 1 attempt
|
||||
const offlineAttempts = await this.quizOfflineProvider.getQuizAttempts(quiz.id, siteId);
|
||||
|
||||
// Now get the list of online attempts to make sure this attempt exists and isn't finished.
|
||||
return this.quizProvider.getUserAttempts(quiz.id, modOptions).then((attempts) => {
|
||||
const lastAttemptId = attempts.length ? attempts[attempts.length - 1].id : undefined;
|
||||
let onlineAttempt;
|
||||
if (!offlineAttempts.length) {
|
||||
// Nothing to sync, finish.
|
||||
return this.finishSync(siteId, quiz, courseId, warnings);
|
||||
}
|
||||
|
||||
// Search the attempt we retrieved from offline.
|
||||
for (const i in attempts) {
|
||||
const attempt = attempts[i];
|
||||
if (!this.appProvider.isOnline()) {
|
||||
// Cannot sync in offline.
|
||||
throw new Error(this.translate.instant('core.cannotconnect'));
|
||||
}
|
||||
|
||||
if (attempt.id == offlineAttempt.id) {
|
||||
onlineAttempt = attempt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const offlineAttempt = offlineAttempts.pop();
|
||||
|
||||
if (!onlineAttempt || this.quizProvider.isAttemptFinished(onlineAttempt.state)) {
|
||||
// Attempt not found or it's finished in online. Discard it.
|
||||
warnings.push(this.translate.instant('addon.mod_quiz.warningattemptfinished'));
|
||||
// Now get the list of online attempts to make sure this attempt exists and isn't finished.
|
||||
const onlineAttempts = await this.quizProvider.getUserAttempts(quiz.id, modOptions);
|
||||
|
||||
return this.finishSync(siteId, quiz, courseId, warnings, offlineAttempt.id, offlineAttempt, onlineAttempt,
|
||||
true);
|
||||
}
|
||||
|
||||
// Get the data stored in offline.
|
||||
return this.quizOfflineProvider.getAttemptAnswers(offlineAttempt.id, siteId).then((answersList) => {
|
||||
|
||||
if (!answersList.length) {
|
||||
// No answers stored, finish.
|
||||
return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt,
|
||||
true);
|
||||
}
|
||||
|
||||
const answers = this.questionProvider.convertAnswersArrayToObject(answersList),
|
||||
offlineQuestions = this.quizOfflineProvider.classifyAnswersInQuestions(answers);
|
||||
let finish;
|
||||
|
||||
// We're going to need preflightData, get it.
|
||||
return this.quizProvider.getQuizAccessInformation(quiz.id, modOptions).then((info) => {
|
||||
|
||||
return this.prefetchHandler.getPreflightData(quiz, info, onlineAttempt, askPreflight,
|
||||
'core.settings.synchronization', siteId);
|
||||
}).then((data) => {
|
||||
preflightData = data;
|
||||
|
||||
// Now get the online questions data.
|
||||
const pages = this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions);
|
||||
|
||||
return this.quizProvider.getAllQuestionsData(quiz, onlineAttempt, preflightData, {
|
||||
pages,
|
||||
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
||||
siteId,
|
||||
});
|
||||
}).then((onlineQuestions) => {
|
||||
|
||||
// Validate questions, discarding the offline answers that can't be synchronized.
|
||||
return this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId);
|
||||
}).then((discardedData) => {
|
||||
|
||||
// Get the answers to send.
|
||||
const answers = this.quizOfflineProvider.extractAnswersFromQuestions(offlineQuestions);
|
||||
finish = offlineAttempt.finished && !discardedData;
|
||||
|
||||
if (discardedData) {
|
||||
if (offlineAttempt.finished) {
|
||||
warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscardedfromfinished'));
|
||||
} else {
|
||||
warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscarded'));
|
||||
}
|
||||
}
|
||||
|
||||
return this.quizProvider.processAttempt(quiz, onlineAttempt, answers, preflightData, finish, false, false,
|
||||
siteId);
|
||||
}).then(() => {
|
||||
|
||||
// Answers sent, now set the current page if the attempt isn't finished.
|
||||
if (!finish) {
|
||||
// Don't pass the quiz instance because we don't want to trigger a Firebase event in this case.
|
||||
return this.quizProvider.logViewAttempt(onlineAttempt.id, offlineAttempt.currentpage, preflightData,
|
||||
false, undefined, siteId).catch(() => {
|
||||
// Ignore errors.
|
||||
});
|
||||
}
|
||||
}).then(() => {
|
||||
|
||||
// Data sent. Finish the sync.
|
||||
return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt,
|
||||
true, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
const lastAttemptId = onlineAttempts.length ? onlineAttempts[onlineAttempts.length - 1].id : undefined;
|
||||
const onlineAttempt = onlineAttempts.find((attempt) => {
|
||||
return attempt.id == offlineAttempt.id;
|
||||
});
|
||||
|
||||
return this.addOngoingSync(quiz.id, syncPromise, siteId);
|
||||
if (!onlineAttempt || this.quizProvider.isAttemptFinished(onlineAttempt.state)) {
|
||||
// Attempt not found or it's finished in online. Discard it.
|
||||
warnings.push(this.translate.instant('addon.mod_quiz.warningattemptfinished'));
|
||||
|
||||
return this.finishSync(siteId, quiz, courseId, warnings, {
|
||||
attemptId: offlineAttempt.id,
|
||||
offlineAttempt,
|
||||
onlineAttempt,
|
||||
removeAttempt: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the data stored in offline.
|
||||
const answersList = await this.quizOfflineProvider.getAttemptAnswers(offlineAttempt.id, siteId);
|
||||
|
||||
if (!answersList.length) {
|
||||
// No answers stored, finish.
|
||||
return this.finishSync(siteId, quiz, courseId, warnings, {
|
||||
attemptId: lastAttemptId,
|
||||
offlineAttempt,
|
||||
onlineAttempt,
|
||||
removeAttempt: true,
|
||||
});
|
||||
}
|
||||
|
||||
const offlineAnswers = this.questionProvider.convertAnswersArrayToObject(answersList);
|
||||
const offlineQuestions = this.quizOfflineProvider.classifyAnswersInQuestions(offlineAnswers);
|
||||
|
||||
// We're going to need preflightData, get it.
|
||||
const info = await this.quizProvider.getQuizAccessInformation(quiz.id, modOptions);
|
||||
|
||||
const preflightData = await this.prefetchHandler.getPreflightData(quiz, info, onlineAttempt, askPreflight,
|
||||
'core.settings.synchronization', siteId);
|
||||
|
||||
// Now get the online questions data.
|
||||
const onlineQuestions = await this.quizProvider.getAllQuestionsData(quiz, onlineAttempt, preflightData, {
|
||||
pages: this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions),
|
||||
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
||||
siteId,
|
||||
});
|
||||
|
||||
// Validate questions, discarding the offline answers that can't be synchronized.
|
||||
const discardedData = await this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId);
|
||||
|
||||
// Let questions prepare the data to send.
|
||||
await Promise.all(Object.keys(offlineQuestions).map(async (slot) => {
|
||||
const onlineQuestion = onlineQuestions[slot];
|
||||
|
||||
await this.questionDelegate.prepareSyncData(onlineQuestion, offlineQuestions[slot].answers,
|
||||
AddonModQuizProvider.COMPONENT, quiz.coursemodule, siteId);
|
||||
}));
|
||||
|
||||
// Get the answers to send.
|
||||
const answers = this.quizOfflineProvider.extractAnswersFromQuestions(offlineQuestions);
|
||||
const finish = offlineAttempt.finished && !discardedData;
|
||||
|
||||
if (discardedData) {
|
||||
if (offlineAttempt.finished) {
|
||||
warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscardedfromfinished'));
|
||||
} else {
|
||||
warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscarded'));
|
||||
}
|
||||
}
|
||||
|
||||
// Send the answers.
|
||||
await this.quizProvider.processAttempt(quiz, onlineAttempt, answers, preflightData, finish, false, false, siteId);
|
||||
|
||||
if (!finish) {
|
||||
// Answers sent, now set the current page.
|
||||
// Don't pass the quiz instance because we don't want to trigger a Firebase event in this case.
|
||||
await CoreUtils.instance.ignoreErrors(this.quizProvider.logViewAttempt(onlineAttempt.id, offlineAttempt.currentpage,
|
||||
preflightData, false, undefined, siteId));
|
||||
}
|
||||
|
||||
// Data sent. Finish the sync.
|
||||
return this.finishSync(siteId, quiz, courseId, warnings, {
|
||||
attemptId: lastAttemptId,
|
||||
offlineAttempt,
|
||||
onlineAttempt,
|
||||
removeAttempt: true,
|
||||
updated: true,
|
||||
onlineQuestions,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -466,3 +489,15 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options to pass to finish sync.
|
||||
*/
|
||||
type FinishSyncOptions = {
|
||||
attemptId?: number; // Last attempt ID.
|
||||
offlineAttempt?: any; // Offline attempt synchronized, if any.
|
||||
onlineAttempt?: any; // Online data for the offline attempt.
|
||||
removeAttempt?: boolean; // Whether the offline data should be removed.
|
||||
updated?: boolean; // Whether the offline data should be removed.
|
||||
onlineQuestions?: any; // Online questions indexed by slot.
|
||||
};
|
||||
|
|
|
@ -215,6 +215,8 @@ export class AddonModQuizProvider {
|
|||
};
|
||||
|
||||
return site.read('mod_quiz_get_attempt_data', params, preSets);
|
||||
}).then((result) => {
|
||||
return this.parseQuestions(result);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -389,7 +391,9 @@ export class AddonModQuizProvider {
|
|||
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
|
||||
};
|
||||
|
||||
return site.read('mod_quiz_get_attempt_review', params, preSets);
|
||||
return site.read('mod_quiz_get_attempt_review', params, preSets).then((result) => {
|
||||
return this.parseQuestions(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -427,6 +431,8 @@ export class AddonModQuizProvider {
|
|||
|
||||
return site.read('mod_quiz_get_attempt_summary', params, preSets).then((response) => {
|
||||
if (response && response.questions) {
|
||||
response = this.parseQuestions(response);
|
||||
|
||||
if (options.loadLocal) {
|
||||
return this.quizOfflineProvider.loadQuestionsLocalStates(attemptId, response.questions, site.getId());
|
||||
}
|
||||
|
@ -1560,6 +1566,27 @@ export class AddonModQuizProvider {
|
|||
siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse questions of a WS response.
|
||||
*
|
||||
* @param result Result to parse.
|
||||
* @return Parsed result.
|
||||
*/
|
||||
parseQuestions(result: any): any {
|
||||
for (let i = 0; i < result.questions.length; i++) {
|
||||
const question = result.questions[i];
|
||||
|
||||
if (!question.settings) {
|
||||
// Site doesn't return settings, stop.
|
||||
break;
|
||||
}
|
||||
|
||||
question.settings = this.textUtils.parseJSON(question.settings, null);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an attempt, saving its data.
|
||||
*
|
||||
|
|
|
@ -40,13 +40,14 @@ export class AddonQbehaviourDeferredCBMHandler implements CoreQuestionBehaviourH
|
|||
* @param component Component the question belongs to.
|
||||
* @param attemptId Attempt ID the question belongs to.
|
||||
* @param question The question.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return New state (or promise resolved with state).
|
||||
*/
|
||||
determineNewState(component: string, attemptId: number, question: any, siteId?: string)
|
||||
determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string)
|
||||
: CoreQuestionState | Promise<CoreQuestionState> {
|
||||
// Depends on deferredfeedback.
|
||||
return this.deferredFeedbackHandler.determineNewStateDeferred(component, attemptId, question, siteId,
|
||||
return this.deferredFeedbackHandler.determineNewStateDeferred(component, attemptId, question, componentId, siteId,
|
||||
this.isCompleteResponse.bind(this), this.isSameResponse.bind(this));
|
||||
}
|
||||
|
||||
|
@ -71,11 +72,13 @@ export class AddonQbehaviourDeferredCBMHandler implements CoreQuestionBehaviourH
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
protected isCompleteResponse(question: any, answers: any): number {
|
||||
protected isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
// First check if the question answer is complete.
|
||||
const complete = this.questionDelegate.isCompleteResponse(question, answers);
|
||||
const complete = this.questionDelegate.isCompleteResponse(question, answers, component, componentId);
|
||||
if (complete > 0) {
|
||||
// Answer is complete, check the user answered CBM too.
|
||||
return answers['-certainty'] ? 1 : 0;
|
||||
|
@ -101,12 +104,14 @@ export class AddonQbehaviourDeferredCBMHandler implements CoreQuestionBehaviourH
|
|||
* @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
protected isSameResponse(question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, newBasicAnswers: any)
|
||||
: boolean {
|
||||
protected isSameResponse(question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, newBasicAnswers: any,
|
||||
component: string, componentId: string | number): boolean {
|
||||
// First check if the question answer is the same.
|
||||
const same = this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers);
|
||||
const same = this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers, component, componentId);
|
||||
if (same) {
|
||||
// Same response, check the CBM is the same too.
|
||||
return prevAnswers['-certainty'] == newAnswers['-certainty'];
|
||||
|
|
|
@ -23,9 +23,11 @@ import { CoreQuestionProvider, CoreQuestionState } from '@core/question/provider
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
export type isCompleteResponseFunction = (question: any, answers: any) => number;
|
||||
export type isCompleteResponseFunction = (question: any, answers: any, component: string, componentId: string | number) => number;
|
||||
|
||||
/**
|
||||
* Check if two responses are the same.
|
||||
|
@ -35,10 +37,12 @@ export type isCompleteResponseFunction = (question: any, answers: any) => number
|
|||
* @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
export type isSameResponseFunction = (question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any,
|
||||
newBasicAnswers: any) => boolean;
|
||||
newBasicAnswers: any, component: string, componentId: string | number) => boolean;
|
||||
|
||||
/**
|
||||
* Handler to support deferred feedback question behaviour.
|
||||
|
@ -58,12 +62,13 @@ export class AddonQbehaviourDeferredFeedbackHandler implements CoreQuestionBehav
|
|||
* @param component Component the question belongs to.
|
||||
* @param attemptId Attempt ID the question belongs to.
|
||||
* @param question The question.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return New state (or promise resolved with state).
|
||||
*/
|
||||
determineNewState(component: string, attemptId: number, question: any, siteId?: string)
|
||||
determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string)
|
||||
: CoreQuestionState | Promise<CoreQuestionState> {
|
||||
return this.determineNewStateDeferred(component, attemptId, question, siteId);
|
||||
return this.determineNewStateDeferred(component, attemptId, question, componentId, siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -72,75 +77,82 @@ export class AddonQbehaviourDeferredFeedbackHandler implements CoreQuestionBehav
|
|||
* @param component Component the question belongs to.
|
||||
* @param attemptId Attempt ID the question belongs to.
|
||||
* @param question The question.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param isCompleteFn Function to override the default isCompleteResponse check.
|
||||
* @param isSameFn Function to override the default isSameResponse check.
|
||||
* @return Promise resolved with state.
|
||||
*/
|
||||
determineNewStateDeferred(component: string, attemptId: number, question: any, siteId?: string,
|
||||
isCompleteFn?: isCompleteResponseFunction, isSameFn?: isSameResponseFunction): Promise<CoreQuestionState> {
|
||||
async determineNewStateDeferred(component: string, attemptId: number, question: any, componentId: string | number,
|
||||
siteId?: string, isCompleteFn?: isCompleteResponseFunction, isSameFn?: isSameResponseFunction)
|
||||
: Promise<CoreQuestionState> {
|
||||
|
||||
// Check if we have local data for the question.
|
||||
return this.questionProvider.getQuestion(component, attemptId, question.slot, siteId).catch(() => {
|
||||
let dbQuestion;
|
||||
try {
|
||||
dbQuestion = await this.questionProvider.getQuestion(component, attemptId, question.slot, siteId);
|
||||
} catch (error) {
|
||||
// No entry found, use the original data.
|
||||
return question;
|
||||
}).then((dbQuestion) => {
|
||||
const state = this.questionProvider.getState(dbQuestion.state);
|
||||
dbQuestion = question;
|
||||
}
|
||||
|
||||
if (state.finished || !state.active) {
|
||||
// Question is finished, it cannot change.
|
||||
return state;
|
||||
const state = this.questionProvider.getState(dbQuestion.state);
|
||||
|
||||
if (state.finished || !state.active) {
|
||||
// Question is finished, it cannot change.
|
||||
return state;
|
||||
}
|
||||
|
||||
const newBasicAnswers = this.questionProvider.getBasicAnswers(question.answers);
|
||||
|
||||
if (dbQuestion.state) {
|
||||
// Question already has a state stored. Check if answer has changed.
|
||||
let prevAnswers = await this.questionProvider.getQuestionAnswers(component, attemptId, question.slot, false, siteId);
|
||||
|
||||
prevAnswers = this.questionProvider.convertAnswersArrayToObject(prevAnswers, true);
|
||||
const prevBasicAnswers = this.questionProvider.getBasicAnswers(prevAnswers);
|
||||
|
||||
// If answers haven't changed the state is the same.
|
||||
if (isSameFn) {
|
||||
if (isSameFn(question, prevAnswers, prevBasicAnswers, question.answers, newBasicAnswers,
|
||||
component, componentId)) {
|
||||
return state;
|
||||
}
|
||||
} else {
|
||||
if (this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers, component, componentId)) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We need to check if the answers have changed. Retrieve current stored answers.
|
||||
return this.questionProvider.getQuestionAnswers(component, attemptId, question.slot, false, siteId)
|
||||
.then((prevAnswers) => {
|
||||
// Answers have changed. Now check if the response is complete and calculate the new state.
|
||||
let complete: number;
|
||||
let newState: string;
|
||||
|
||||
const newBasicAnswers = this.questionProvider.getBasicAnswers(question.answers);
|
||||
if (isCompleteFn) {
|
||||
// Pass all the answers since some behaviours might need the extra data.
|
||||
complete = isCompleteFn(question, question.answers, component, componentId);
|
||||
} else {
|
||||
// Only pass the basic answers since questions should be independent of extra data.
|
||||
complete = this.questionDelegate.isCompleteResponse(question, newBasicAnswers, component, componentId);
|
||||
}
|
||||
|
||||
prevAnswers = this.questionProvider.convertAnswersArrayToObject(prevAnswers, true);
|
||||
const prevBasicAnswers = this.questionProvider.getBasicAnswers(prevAnswers);
|
||||
if (complete < 0) {
|
||||
newState = 'cannotdeterminestatus';
|
||||
} else if (complete > 0) {
|
||||
newState = 'complete';
|
||||
} else {
|
||||
const gradable = this.questionDelegate.isGradableResponse(question, newBasicAnswers, component, componentId);
|
||||
if (gradable < 0) {
|
||||
newState = 'cannotdeterminestatus';
|
||||
} else if (gradable > 0) {
|
||||
newState = 'invalid';
|
||||
} else {
|
||||
newState = 'todo';
|
||||
}
|
||||
}
|
||||
|
||||
// If answers haven't changed the state is the same.
|
||||
if (isSameFn) {
|
||||
if (isSameFn(question, prevAnswers, prevBasicAnswers, question.answers, newBasicAnswers)) {
|
||||
return state;
|
||||
}
|
||||
} else {
|
||||
if (this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers)) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// Answers have changed. Now check if the response is complete and calculate the new state.
|
||||
let complete: number,
|
||||
newState: string;
|
||||
if (isCompleteFn) {
|
||||
// Pass all the answers since some behaviours might need the extra data.
|
||||
complete = isCompleteFn(question, question.answers);
|
||||
} else {
|
||||
// Only pass the basic answers since questions should be independent of extra data.
|
||||
complete = this.questionDelegate.isCompleteResponse(question, newBasicAnswers);
|
||||
}
|
||||
|
||||
if (complete < 0) {
|
||||
newState = 'cannotdeterminestatus';
|
||||
} else if (complete > 0) {
|
||||
newState = 'complete';
|
||||
} else {
|
||||
const gradable = this.questionDelegate.isGradableResponse(question, newBasicAnswers);
|
||||
if (gradable < 0) {
|
||||
newState = 'cannotdeterminestatus';
|
||||
} else if (gradable > 0) {
|
||||
newState = 'invalid';
|
||||
} else {
|
||||
newState = 'todo';
|
||||
}
|
||||
}
|
||||
|
||||
return this.questionProvider.getState(newState);
|
||||
});
|
||||
});
|
||||
return this.questionProvider.getState(newState);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -35,10 +35,11 @@ export class AddonQbehaviourInformationItemHandler implements CoreQuestionBehavi
|
|||
* @param component Component the question belongs to.
|
||||
* @param attemptId Attempt ID the question belongs to.
|
||||
* @param question The question.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return New state (or promise resolved with state).
|
||||
*/
|
||||
determineNewState(component: string, attemptId: number, question: any, siteId?: string)
|
||||
determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string)
|
||||
: CoreQuestionState | Promise<CoreQuestionState> {
|
||||
if (question.answers['-seen']) {
|
||||
return this.questionProvider.getState('complete');
|
||||
|
|
|
@ -17,28 +17,7 @@ import { Injectable } from '@angular/core';
|
|||
import { CoreQuestionBehaviourHandler } from '@core/question/providers/behaviour-delegate';
|
||||
import { CoreQuestionDelegate } from '@core/question/providers/delegate';
|
||||
import { CoreQuestionProvider, CoreQuestionState } from '@core/question/providers/question';
|
||||
|
||||
/**
|
||||
* Check if a response is complete.
|
||||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
export type isCompleteResponseFunction = (question: any, answers: any) => number;
|
||||
|
||||
/**
|
||||
* Check if two responses are the same.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
export type isSameResponseFunction = (question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any,
|
||||
newBasicAnswers: any) => boolean;
|
||||
import { AddonQbehaviourDeferredFeedbackHandler } from '@addon/qbehaviour/deferredfeedback/providers/handler';
|
||||
|
||||
/**
|
||||
* Handler to support manual graded question behaviour.
|
||||
|
@ -48,7 +27,9 @@ export class AddonQbehaviourManualGradedHandler implements CoreQuestionBehaviour
|
|||
name = 'AddonQbehaviourManualGraded';
|
||||
type = 'manualgraded';
|
||||
|
||||
constructor(private questionDelegate: CoreQuestionDelegate, private questionProvider: CoreQuestionProvider) {
|
||||
constructor(protected questionDelegate: CoreQuestionDelegate,
|
||||
protected questionProvider: CoreQuestionProvider,
|
||||
protected deferredFeedbackHandler: AddonQbehaviourDeferredFeedbackHandler) {
|
||||
// Nothing to do.
|
||||
}
|
||||
|
||||
|
@ -58,82 +39,14 @@ export class AddonQbehaviourManualGradedHandler implements CoreQuestionBehaviour
|
|||
* @param component Component the question belongs to.
|
||||
* @param attemptId Attempt ID the question belongs to.
|
||||
* @param question The question.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return New state (or promise resolved with state).
|
||||
*/
|
||||
determineNewState(component: string, attemptId: number, question: any, siteId?: string)
|
||||
determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string)
|
||||
: CoreQuestionState | Promise<CoreQuestionState> {
|
||||
return this.determineNewStateManualGraded(component, attemptId, question, siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine a question new state based on its answer(s) for manual graded question behaviour.
|
||||
*
|
||||
* @param component Component the question belongs to.
|
||||
* @param attemptId Attempt ID the question belongs to.
|
||||
* @param question The question.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param isCompleteFn Function to override the default isCompleteResponse check.
|
||||
* @param isSameFn Function to override the default isSameResponse check.
|
||||
* @return Promise resolved with state.
|
||||
*/
|
||||
determineNewStateManualGraded(component: string, attemptId: number, question: any, siteId?: string,
|
||||
isCompleteFn?: isCompleteResponseFunction, isSameFn?: isSameResponseFunction): Promise<CoreQuestionState> {
|
||||
|
||||
// Check if we have local data for the question.
|
||||
return this.questionProvider.getQuestion(component, attemptId, question.slot, siteId).catch(() => {
|
||||
// No entry found, use the original data.
|
||||
return question;
|
||||
}).then((dbQuestion) => {
|
||||
const state = this.questionProvider.getState(dbQuestion.state);
|
||||
|
||||
if (state.finished || !state.active) {
|
||||
// Question is finished, it cannot change.
|
||||
return state;
|
||||
}
|
||||
|
||||
// We need to check if the answers have changed. Retrieve current stored answers.
|
||||
return this.questionProvider.getQuestionAnswers(component, attemptId, question.slot, false, siteId)
|
||||
.then((prevAnswers) => {
|
||||
|
||||
const newBasicAnswers = this.questionProvider.getBasicAnswers(question.answers);
|
||||
|
||||
prevAnswers = this.questionProvider.convertAnswersArrayToObject(prevAnswers, true);
|
||||
const prevBasicAnswers = this.questionProvider.getBasicAnswers(prevAnswers);
|
||||
|
||||
// If answers haven't changed the state is the same.
|
||||
if (isSameFn) {
|
||||
if (isSameFn(question, prevAnswers, prevBasicAnswers, question.answers, newBasicAnswers)) {
|
||||
return state;
|
||||
}
|
||||
} else {
|
||||
if (this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers)) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// Answers have changed. Now check if the response is complete and calculate the new state.
|
||||
let complete: number,
|
||||
newState: string;
|
||||
if (isCompleteFn) {
|
||||
// Pass all the answers since some behaviours might need the extra data.
|
||||
complete = isCompleteFn(question, question.answers);
|
||||
} else {
|
||||
// Only pass the basic answers since questions should be independent of extra data.
|
||||
complete = this.questionDelegate.isCompleteResponse(question, newBasicAnswers);
|
||||
}
|
||||
|
||||
if (complete < 0) {
|
||||
newState = 'cannotdeterminestatus';
|
||||
} else if (complete > 0) {
|
||||
newState = 'complete';
|
||||
} else {
|
||||
newState = 'todo';
|
||||
}
|
||||
|
||||
return this.questionProvider.getState(newState);
|
||||
});
|
||||
});
|
||||
// Same implementation as the deferred feedback. Use that function instead of replicating it.
|
||||
return this.deferredFeedbackHandler.determineNewStateDeferred(component, attemptId, question, componentId, siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -24,6 +24,14 @@ import { AddonQtypeCalculatedComponent } from '../component/calculated';
|
|||
*/
|
||||
@Injectable()
|
||||
export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
|
||||
static UNITINPUT = '0';
|
||||
static UNITRADIO = '1';
|
||||
static UNITSELECT = '2';
|
||||
static UNITNONE = '3';
|
||||
|
||||
static UNITGRADED = '1';
|
||||
static UNITOPTIONAL = '0';
|
||||
|
||||
name = 'AddonQtypeCalculated';
|
||||
type = 'qtype_calculated';
|
||||
|
||||
|
@ -41,23 +49,69 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
|
|||
return AddonQtypeCalculatedComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the units are in a separate field for the question.
|
||||
*
|
||||
* @param question Question.
|
||||
* @return Whether units are in a separate field.
|
||||
*/
|
||||
hasSeparateUnitField(question: any): boolean {
|
||||
if (!question.settings) {
|
||||
const element = this.domUtils.convertToElement(question.html);
|
||||
|
||||
return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]'));
|
||||
}
|
||||
|
||||
return question.settings.unitdisplay === AddonQtypeCalculatedHandler.UNITRADIO ||
|
||||
question.settings.unitdisplay === AddonQtypeCalculatedHandler.UNITSELECT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a response is complete.
|
||||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
if (this.isGradableResponse(question, answers) === 0 || !this.validateUnits(answers['answer'])) {
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
if (!this.isGradableResponse(question, answers, component, componentId)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (this.requiresUnits(question)) {
|
||||
return this.isValidValue(answers['unit']) ? 1 : 0;
|
||||
const parsedAnswer = this.parseAnswer(question, answers['answer']);
|
||||
if (parsedAnswer.answer === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return -1;
|
||||
if (!question.settings) {
|
||||
if (this.hasSeparateUnitField(question)) {
|
||||
return this.isValidValue(answers['unit']) ? 1 : 0;
|
||||
}
|
||||
|
||||
// We cannot know if the answer should contain units or not.
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (question.settings.unitdisplay != AddonQtypeCalculatedHandler.UNITINPUT && parsedAnswer.unit) {
|
||||
// There should be no units or be outside of the input, not valid.
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (this.hasSeparateUnitField(question) && !this.isValidValue(answers['unit'])) {
|
||||
// Unit not supplied as a separate field and it's required.
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (question.settings.unitdisplay == AddonQtypeCalculatedHandler.UNITINPUT &&
|
||||
question.settings.unitgradingtype == AddonQtypeCalculatedHandler.UNITGRADED &&
|
||||
!this.isValidValue(parsedAnswer.unit)) {
|
||||
// Unit not supplied inside the input and it's required.
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -75,16 +129,12 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
let isGradable = this.isValidValue(answers['answer']);
|
||||
if (isGradable && this.requiresUnits(question)) {
|
||||
// The question requires a unit.
|
||||
isGradable = this.isValidValue(answers['unit']);
|
||||
}
|
||||
|
||||
return isGradable ? 1 : 0;
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
return this.isValidValue(answers['answer']) ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -93,9 +143,11 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') &&
|
||||
this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'unit');
|
||||
}
|
||||
|
@ -111,36 +163,24 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
|
|||
}
|
||||
|
||||
/**
|
||||
* Check if a question requires units in a separate input.
|
||||
*
|
||||
* @param question The question.
|
||||
* @return Whether the question requires units.
|
||||
*/
|
||||
requiresUnits(question: any): boolean {
|
||||
const element = this.domUtils.convertToElement(question.html);
|
||||
|
||||
return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a number with units. We don't have the list of valid units and conversions, so we can't perform
|
||||
* a full validation. If this function returns true it means we can't be sure it's valid.
|
||||
* Parse an answer string.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param answer Answer.
|
||||
* @return False if answer isn't valid, true if we aren't sure if it's valid.
|
||||
* @return 0 if answer isn't valid, 1 if answer is valid, -1 if we aren't sure if it's valid.
|
||||
*/
|
||||
validateUnits(answer: string): boolean {
|
||||
parseAnswer(question: any, answer: string): {answer: number, unit: string} {
|
||||
if (!answer) {
|
||||
return false;
|
||||
return {answer: null, unit: null};
|
||||
}
|
||||
|
||||
const regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?';
|
||||
let regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?';
|
||||
|
||||
// Strip spaces (which may be thousands separators) and change other forms of writing e to e.
|
||||
answer = answer.replace(' ', '');
|
||||
answer = answer.replace(/ /g, '');
|
||||
answer = answer.replace(/(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)/, 'e$1');
|
||||
|
||||
// If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and stip it.
|
||||
// If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and strip it.
|
||||
// Else assume it is a decimal separator, and change it to '.'.
|
||||
if (answer.indexOf('.') != -1 || answer.split(',').length - 1 > 1) {
|
||||
answer = answer.replace(',', '');
|
||||
|
@ -148,11 +188,31 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
|
|||
answer = answer.replace(',', '.');
|
||||
}
|
||||
|
||||
// We don't know if units should be before or after so we check both.
|
||||
if (answer.match(new RegExp('^' + regexString)) === null || answer.match(new RegExp(regexString + '$')) === null) {
|
||||
return false;
|
||||
let unitsLeft = false;
|
||||
let match = null;
|
||||
|
||||
if (!question.settings) {
|
||||
// We don't know if units should be before or after so we check both.
|
||||
match = answer.match(new RegExp('^' + regexString));
|
||||
if (!match) {
|
||||
unitsLeft = true;
|
||||
match = answer.match(new RegExp(regexString + '$'));
|
||||
}
|
||||
} else {
|
||||
unitsLeft = question.settings.unitsleft == '1';
|
||||
regexString = unitsLeft ? regexString + '$' : '^' + regexString;
|
||||
|
||||
match = answer.match(new RegExp(regexString));
|
||||
}
|
||||
|
||||
return true;
|
||||
if (!match) {
|
||||
return {answer: null, unit: null};
|
||||
}
|
||||
|
||||
const numberString = match[0];
|
||||
const unit = unitsLeft ? answer.substr(0, answer.length - match[0].length) : answer.substr(match[0].length);
|
||||
|
||||
// No need to calculate the multiplier.
|
||||
return {answer: Number(numberString), unit: unit};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,9 +46,11 @@ export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
// This question type depends on multichoice.
|
||||
return this.multichoiceHandler.isCompleteResponseSingle(answers);
|
||||
}
|
||||
|
@ -68,9 +70,11 @@ export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
// This question type depends on multichoice.
|
||||
return this.multichoiceHandler.isGradableResponseSingle(answers);
|
||||
}
|
||||
|
@ -81,9 +85,11 @@ export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
// This question type depends on multichoice.
|
||||
return this.multichoiceHandler.isSameResponseSingle(prevAnswers, newAnswers);
|
||||
}
|
||||
|
|
|
@ -46,11 +46,13 @@ export class AddonQtypeCalculatedSimpleHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
// This question type depends on calculated.
|
||||
return this.calculatedHandler.isCompleteResponse(question, answers);
|
||||
return this.calculatedHandler.isCompleteResponse(question, answers, component, componentId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -68,11 +70,13 @@ export class AddonQtypeCalculatedSimpleHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
// This question type depends on calculated.
|
||||
return this.calculatedHandler.isGradableResponse(question, answers);
|
||||
return this.calculatedHandler.isGradableResponse(question, answers, component, componentId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -81,10 +85,12 @@ export class AddonQtypeCalculatedSimpleHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
// This question type depends on calculated.
|
||||
return this.calculatedHandler.isSameResponse(question, prevAnswers, newAnswers);
|
||||
return this.calculatedHandler.isSameResponse(question, prevAnswers, newAnswers, component, componentId);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,9 +61,11 @@ export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
// An answer is complete if all drop zones have an answer.
|
||||
// We should always receive all the drop zones with their value ('' if not answered).
|
||||
for (const name in answers) {
|
||||
|
@ -91,9 +93,11 @@ export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
for (const name in answers) {
|
||||
const value = answers[name];
|
||||
if (value && value !== '0') {
|
||||
|
@ -110,9 +114,11 @@ export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -62,9 +62,11 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
// If 1 dragitem is set we assume the answer is complete (like Moodle does).
|
||||
for (const name in answers) {
|
||||
if (answers[name]) {
|
||||
|
@ -90,10 +92,12 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
return this.isCompleteResponse(question, answers);
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
return this.isCompleteResponse(question, answers, component, componentId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -102,9 +106,11 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers);
|
||||
}
|
||||
|
||||
|
|
|
@ -61,9 +61,11 @@ export class AddonQtypeDdwtosHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
for (const name in answers) {
|
||||
const value = answers[name];
|
||||
if (!value || value === '0') {
|
||||
|
@ -89,9 +91,11 @@ export class AddonQtypeDdwtosHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
for (const name in answers) {
|
||||
const value = answers[name];
|
||||
if (value && value !== '0') {
|
||||
|
@ -108,9 +112,11 @@ export class AddonQtypeDdwtosHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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" [allowOffline]="offlineEnabled" [acceptedTypes]="question.attachmentsAcceptedTypes"></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>
|
||||
|
||||
|
|
|
@ -14,8 +14,12 @@
|
|||
|
||||
import { Component, OnInit, Injector } from '@angular/core';
|
||||
import { CoreLoggerProvider } from '@providers/logger';
|
||||
import { CoreWSExternalFile } from '@providers/ws';
|
||||
import { CoreFileUploader } from '@core/fileuploader/providers/fileuploader';
|
||||
import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
|
||||
import { CoreQuestion } from '@core/question/providers/question';
|
||||
import { FormControl, FormBuilder } from '@angular/forms';
|
||||
import { CoreFileSession } from '@providers/file-session';
|
||||
|
||||
/**
|
||||
* Component to render an essay question.
|
||||
|
@ -28,6 +32,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 +43,39 @@ export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implemen
|
|||
* Component being initialized.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.uploadFilesSupported = typeof this.question.responsefileareas != 'undefined';
|
||||
this.initEssayComponent();
|
||||
|
||||
this.formControl = this.fb.control(this.question.textarea && this.question.textarea.text);
|
||||
|
||||
if (this.question.allowsAttachments && this.uploadFilesSupported) {
|
||||
this.loadAttachments();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load attachments.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async loadAttachments(): Promise<void> {
|
||||
if (this.offlineEnabled && this.question.localAnswers['attachments_offline']) {
|
||||
|
||||
const attachmentsData = this.textUtils.parseJSON(this.question.localAnswers['attachments_offline'], {});
|
||||
let offlineFiles = [];
|
||||
|
||||
if (attachmentsData.offline) {
|
||||
offlineFiles = await this.questionHelper.getStoredQuestionFiles(this.question, this.component, this.componentId);
|
||||
|
||||
offlineFiles = CoreFileUploader.instance.markOfflineFiles(offlineFiles);
|
||||
}
|
||||
|
||||
this.attachments = (attachmentsData.online || []).concat(offlineFiles);
|
||||
} else {
|
||||
this.attachments = Array.from(this.questionHelper.getResponseFileAreaFiles(this.question, 'attachments'));
|
||||
}
|
||||
|
||||
CoreFileSession.instance.setFiles(this.component,
|
||||
CoreQuestion.instance.getQuestionComponentId(this.question, this.componentId), this.attachments);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,11 +14,15 @@
|
|||
// limitations under the License.
|
||||
|
||||
import { Injectable, Injector } from '@angular/core';
|
||||
import { CoreFileSession } from '@providers/file-session';
|
||||
import { CoreSites } from '@providers/sites';
|
||||
import { CoreTextUtilsProvider } from '@providers/utils/text';
|
||||
import { CoreDomUtilsProvider } from '@providers/utils/dom';
|
||||
import { CoreUtilsProvider } from '@providers/utils/utils';
|
||||
import { CoreFileUploader } from '@core/fileuploader/providers/fileuploader';
|
||||
import { CoreQuestionHandler } from '@core/question/providers/delegate';
|
||||
import { CoreQuestionHelperProvider } from '@core/question/providers/helper';
|
||||
import { CoreQuestion } from '@core/question/providers/question';
|
||||
import { AddonQtypeEssayComponent } from '../component/essay';
|
||||
|
||||
/**
|
||||
|
@ -32,6 +36,59 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
|
|||
constructor(private utils: CoreUtilsProvider, private questionHelper: CoreQuestionHelperProvider,
|
||||
private textUtils: CoreTextUtilsProvider, private domUtils: CoreDomUtilsProvider) { }
|
||||
|
||||
/**
|
||||
* Clear temporary data after the data has been saved.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
*/
|
||||
clearTmpData(question: any, component: string, componentId: string | number): void {
|
||||
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
|
||||
const files = CoreFileSession.instance.getFiles(component, questionComponentId);
|
||||
|
||||
// Clear the files in session for this question.
|
||||
CoreFileSession.instance.clearFiles(component, questionComponentId);
|
||||
|
||||
// Now delete the local files from the tmp folder.
|
||||
CoreFileUploader.instance.clearTmpFiles(files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete any stored data for the question.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
deleteOfflineData(question: any, component: string, componentId: string | number, siteId?: string): Promise<void> {
|
||||
return this.questionHelper.deleteStoredQuestionFiles(question, component, componentId, siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the question allows text and/or attachments.
|
||||
*
|
||||
* @param question Question to check.
|
||||
* @return Allowed options.
|
||||
*/
|
||||
protected getAllowedOptions(question: any): {text: boolean, attachments: boolean} {
|
||||
if (question.settings) {
|
||||
return {
|
||||
text: question.settings.responseformat != 'noinline',
|
||||
attachments: question.settings.attachments != '0',
|
||||
};
|
||||
} else {
|
||||
const element = this.domUtils.convertToElement(question.html);
|
||||
|
||||
return {
|
||||
text: !!element.querySelector('textarea[name*=_answer]'),
|
||||
attachments: !!element.querySelector('div[id*=filemanager]'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
@ -65,14 +122,15 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
|
|||
*/
|
||||
getPreventSubmitMessage(question: any): string {
|
||||
const element = this.domUtils.convertToElement(question.html);
|
||||
const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
|
||||
|
||||
if (element.querySelector('div[id*=filemanager]')) {
|
||||
if (!uploadFilesSupported && 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';
|
||||
if (!uploadFilesSupported && this.questionHelper.hasDraftFileUrls(element.innerHTML)) {
|
||||
return 'core.question.errorinlinefilesnotsupportedinsite';
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -81,20 +139,34 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
const element = this.domUtils.convertToElement(question.html);
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
|
||||
const hasInlineText = answers['answer'] && answers['answer'] !== '',
|
||||
allowsAttachments = !!element.querySelector('div[id*=filemanager]');
|
||||
const hasTextAnswer = answers['answer'] && answers['answer'] !== '';
|
||||
const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
|
||||
const allowedOptions = this.getAllowedOptions(question);
|
||||
|
||||
if (!allowsAttachments) {
|
||||
return hasInlineText ? 1 : 0;
|
||||
if (!allowedOptions.attachments) {
|
||||
return hasTextAnswer ? 1 : 0;
|
||||
}
|
||||
|
||||
// We can't know if the attachments are required or if the user added any in web.
|
||||
return -1;
|
||||
if (!uploadFilesSupported) {
|
||||
// We can't know if the attachments are required or if the user added any in web.
|
||||
return -1;
|
||||
}
|
||||
|
||||
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
|
||||
const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
|
||||
|
||||
if (!allowedOptions.text) {
|
||||
return attachments && attachments.length >= Number(question.settings.attachmentsrequired) ? 1 : 0;
|
||||
}
|
||||
|
||||
return (hasTextAnswer || question.settings.responserequired == '0') &&
|
||||
(attachments && attachments.length > Number(question.settings.attachmentsrequired)) ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -112,10 +184,20 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
return 0;
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
if (typeof question.responsefileareas == 'undefined') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
|
||||
const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
|
||||
|
||||
// Determine if the given response has online text or attachments.
|
||||
return (answers['answer'] && answers['answer'] !== '') || (attachments && attachments.length > 0) ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -124,30 +206,165 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
|
||||
const allowedOptions = this.getAllowedOptions(question);
|
||||
|
||||
// First check the inline text.
|
||||
const answerIsEqual = allowedOptions.text ? this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') : true;
|
||||
|
||||
if (!allowedOptions.attachments || !uploadFilesSupported || !answerIsEqual) {
|
||||
// No need to check attachments.
|
||||
return answerIsEqual;
|
||||
}
|
||||
|
||||
// Check attachments now.
|
||||
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
|
||||
const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
|
||||
const originalAttachments = this.questionHelper.getResponseFileAreaFiles(question, 'attachments');
|
||||
|
||||
return !CoreFileUploader.instance.areFileListDifferent(attachments, originalAttachments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare and add to answers the data to send to server based in the input. Return promise if async.
|
||||
* Prepare and add to answers the data to send to server based in the input.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
|
||||
* @param offline Whether the data should be saved in offline.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @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, component: string, componentId: string | number,
|
||||
siteId?: string): Promise<void> {
|
||||
|
||||
const element = this.domUtils.convertToElement(question.html);
|
||||
const attachmentsInput = <HTMLInputElement> element.querySelector('.attachments input[name*=_attachments]');
|
||||
|
||||
// Search the textarea to get its name.
|
||||
const textarea = <HTMLTextAreaElement> element.querySelector('textarea[name*=_answer]');
|
||||
|
||||
if (textarea && typeof answers[textarea.name] != 'undefined') {
|
||||
// Add some HTML to the text if needed.
|
||||
answers[textarea.name] = this.textUtils.formatHtmlLines(answers[textarea.name]);
|
||||
await this.prepareTextAnswer(question, answers, textarea, siteId);
|
||||
}
|
||||
|
||||
if (attachmentsInput) {
|
||||
await this.prepareAttachments(question, answers, offline, component, componentId, attachmentsInput, siteId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare attachments.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
|
||||
* @param offline Whether the data should be saved in offline.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @param attachmentsInput The HTML input containing the draft ID for attachments.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Return a promise resolved when done if async, void if sync.
|
||||
*/
|
||||
async prepareAttachments(question: any, answers: any, offline: boolean, component: string, componentId: string | number,
|
||||
attachmentsInput: HTMLInputElement, siteId?: string): Promise<void> {
|
||||
|
||||
// Treat attachments if any.
|
||||
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
|
||||
const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
|
||||
const draftId = Number(attachmentsInput.value);
|
||||
|
||||
if (offline) {
|
||||
// Get the folder where to store the files.
|
||||
const folderPath = CoreQuestion.instance.getQuestionFolder(question.type, component, questionComponentId, siteId);
|
||||
|
||||
const result = await CoreFileUploader.instance.storeFilesToUpload(folderPath, attachments);
|
||||
|
||||
// Store the files in the answers.
|
||||
answers[attachmentsInput.name + '_offline'] = JSON.stringify(result);
|
||||
} else {
|
||||
// Check if any attachment was deleted.
|
||||
const originalAttachments = this.questionHelper.getResponseFileAreaFiles(question, 'attachments');
|
||||
const filesToDelete = CoreFileUploader.instance.getFilesToDelete(originalAttachments, attachments);
|
||||
|
||||
if (filesToDelete.length > 0) {
|
||||
// Delete files.
|
||||
await CoreFileUploader.instance.deleteDraftFiles(draftId, filesToDelete, siteId);
|
||||
}
|
||||
|
||||
await CoreFileUploader.instance.uploadFiles(draftId, attachments, siteId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare data to send when performing a synchronization.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param answers Answers of the question, without the prefix.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async prepareSyncData(question: any, answers: {[name: string]: any}, component: string, componentId: string | number,
|
||||
siteId?: string): Promise<void> {
|
||||
|
||||
const element = this.domUtils.convertToElement(question.html);
|
||||
const attachmentsInput = <HTMLInputElement> element.querySelector('.attachments input[name*=_attachments]');
|
||||
|
||||
if (attachmentsInput) {
|
||||
// Update the draft ID, the stored one could no longer be valid.
|
||||
answers.attachments = attachmentsInput.value;
|
||||
}
|
||||
|
||||
if (answers && answers.attachments_offline) {
|
||||
const attachmentsData = this.textUtils.parseJSON(answers.attachments_offline, {});
|
||||
|
||||
// Check if any attachment was deleted.
|
||||
const originalAttachments = this.questionHelper.getResponseFileAreaFiles(question, 'attachments');
|
||||
const filesToDelete = CoreFileUploader.instance.getFilesToDelete(originalAttachments, attachmentsData.online);
|
||||
|
||||
if (filesToDelete.length > 0) {
|
||||
// Delete files.
|
||||
await CoreFileUploader.instance.deleteDraftFiles(answers.attachments, filesToDelete, siteId);
|
||||
}
|
||||
|
||||
if (attachmentsData.offline) {
|
||||
// Upload the offline files.
|
||||
const offlineFiles = await this.questionHelper.getStoredQuestionFiles(question, component, componentId, siteId);
|
||||
|
||||
await CoreFileUploader.instance.uploadFiles(answers.attachments, attachmentsData.online.concat(offlineFiles),
|
||||
siteId);
|
||||
}
|
||||
|
||||
delete answers.attachments_offline;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the text answer.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
|
||||
* @param textarea The textarea HTML element of the question.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async prepareTextAnswer(question: any, answers: any, textarea: HTMLTextAreaElement, siteId?: string): Promise<void> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,9 +61,11 @@ export class AddonQtypeGapSelectHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
// We should always get a value for each select so we can assume we receive all the possible answers.
|
||||
for (const name in answers) {
|
||||
const value = answers[name];
|
||||
|
@ -90,9 +92,11 @@ export class AddonQtypeGapSelectHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
// We should always get a value for each select so we can assume we receive all the possible answers.
|
||||
for (const name in answers) {
|
||||
const value = answers[name];
|
||||
|
@ -110,9 +114,11 @@ export class AddonQtypeGapSelectHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,9 +61,11 @@ export class AddonQtypeMatchHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
// We should always get a value for each select so we can assume we receive all the possible answers.
|
||||
for (const name in answers) {
|
||||
const value = answers[name];
|
||||
|
@ -90,9 +92,11 @@ export class AddonQtypeMatchHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
// We should always get a value for each select so we can assume we receive all the possible answers.
|
||||
for (const name in answers) {
|
||||
const value = answers[name];
|
||||
|
@ -110,9 +114,11 @@ export class AddonQtypeMatchHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -62,9 +62,11 @@ export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
// Get all the inputs in the question to check if they've all been answered.
|
||||
const names = this.questionProvider.getBasicAnswers(this.questionHelper.getAllInputNamesFromHtml(question.html));
|
||||
for (const name in names) {
|
||||
|
@ -92,9 +94,11 @@ export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
// We should always get a value for each select so we can assume we receive all the possible answers.
|
||||
for (const name in answers) {
|
||||
const value = answers[name];
|
||||
|
@ -112,9 +116,11 @@ export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers);
|
||||
}
|
||||
|
||||
|
|
|
@ -45,9 +45,11 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
let isSingle = true,
|
||||
isMultiComplete = false;
|
||||
|
||||
|
@ -95,10 +97,12 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
return this.isCompleteResponse(question, answers);
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
return this.isCompleteResponse(question, answers, component, componentId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -118,9 +122,11 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
let isSingle = true,
|
||||
isMultiSame = true;
|
||||
|
||||
|
@ -158,10 +164,13 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
|
||||
* @param offline Whether the data should be saved in offline.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @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> {
|
||||
prepareAnswers(question: any, answers: any, offline: boolean, component: string, componentId: string | number, siteId?: string)
|
||||
: void | Promise<any> {
|
||||
if (question && !question.multi && typeof answers[question.optionsName] != 'undefined' && !answers[question.optionsName]) {
|
||||
/* It's a single choice and the user hasn't answered. Delete the answer because
|
||||
sending an empty string (default value) will mark the first option as selected. */
|
||||
|
|
|
@ -46,11 +46,13 @@ export class AddonQtypeRandomSaMatchHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
// This question behaves like a match question.
|
||||
return this.matchHandler.isCompleteResponse(question, answers);
|
||||
return this.matchHandler.isCompleteResponse(question, answers, component, componentId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -68,11 +70,13 @@ export class AddonQtypeRandomSaMatchHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
// This question behaves like a match question.
|
||||
return this.matchHandler.isGradableResponse(question, answers);
|
||||
return this.matchHandler.isGradableResponse(question, answers, component, componentId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -81,10 +85,12 @@ export class AddonQtypeRandomSaMatchHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
// This question behaves like a match question.
|
||||
return this.matchHandler.isSameResponse(question, prevAnswers, newAnswers);
|
||||
return this.matchHandler.isSameResponse(question, prevAnswers, newAnswers, component, componentId);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,9 +45,11 @@ export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
return (answers['answer'] || answers['answer'] === 0) ? 1 : 0;
|
||||
}
|
||||
|
||||
|
@ -66,10 +68,12 @@ export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
return this.isCompleteResponse(question, answers);
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
return this.isCompleteResponse(question, answers, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -78,9 +82,11 @@ export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,9 +46,11 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
return answers['answer'] ? 1 : 0;
|
||||
}
|
||||
|
||||
|
@ -67,10 +69,12 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
return this.isCompleteResponse(question, answers);
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
return this.isCompleteResponse(question, answers, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -79,9 +83,11 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
|
||||
}
|
||||
|
||||
|
@ -91,10 +97,13 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
|
||||
* @param offline Whether the data should be saved in offline.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @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> {
|
||||
prepareAnswers(question: any, answers: any, offline: boolean, component: string, componentId: string | number, siteId?: string)
|
||||
: void | Promise<any> {
|
||||
if (question && typeof answers[question.optionsName] != 'undefined' && !answers[question.optionsName]) {
|
||||
// The user hasn't answered. Delete the answer to prevent marking one of the answers automatically.
|
||||
delete answers[question.optionsName];
|
||||
|
|
|
@ -1861,6 +1861,7 @@
|
|||
"core.mainmenu.help": "Help",
|
||||
"core.mainmenu.logout": "Log out",
|
||||
"core.mainmenu.website": "Website",
|
||||
"core.maxfilesize": "Maximum size for new files: {{$a}}",
|
||||
"core.maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}",
|
||||
"core.min": "min",
|
||||
"core.mins": "mins",
|
||||
|
@ -1944,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.errorembeddedfilesnotsupportedinsite": "Your site doesn't support editing embedded 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.",
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -39,8 +39,8 @@ import { CoreFileUploaderHelperProvider } from '@core/fileuploader/providers/hel
|
|||
})
|
||||
export class CoreAttachmentsComponent implements OnInit {
|
||||
@Input() files: any[]; // List of attachments. New attachments will be added to this array.
|
||||
@Input() maxSize: number; // Max size for attachments. If not defined, 0 or -1, unknown size.
|
||||
@Input() maxSubmissions: number; // Max number of attachments. If -1 or not defined, unknown limit.
|
||||
@Input() maxSize: number; // Max size for attachments. -1 means unlimited, not defined or 0 means unknown limit.
|
||||
@Input() maxSubmissions: number; // Max number of attachments. -1 means unlimited, not defined means unknown limit.
|
||||
@Input() component: string; // Component the downloaded files will be linked to.
|
||||
@Input() componentId: string | number; // Component ID.
|
||||
@Input() allowOffline: boolean | string; // Whether to allow selecting files in offline.
|
||||
|
@ -61,17 +61,18 @@ export class CoreAttachmentsComponent implements OnInit {
|
|||
* Component being initialized.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.maxSize = Number(this.maxSize); // Make sure it's defined and it's a number.
|
||||
this.maxSize = !isNaN(this.maxSize) && this.maxSize > 0 ? this.maxSize : -1;
|
||||
this.maxSize = Number(this.maxSize) || 0; // Make sure it's defined and it's a number.
|
||||
|
||||
if (this.maxSize == -1) {
|
||||
if (this.maxSize === 0) {
|
||||
this.maxSizeReadable = this.translate.instant('core.unknown');
|
||||
} else {
|
||||
} else if (this.maxSize > 0) {
|
||||
this.maxSizeReadable = this.textUtils.bytesToSize(this.maxSize, 2);
|
||||
} else {
|
||||
this.maxSizeReadable = this.translate.instant('core.unlimited');
|
||||
}
|
||||
|
||||
if (typeof this.maxSubmissions == 'undefined' || this.maxSubmissions < 0) {
|
||||
this.maxSubmissionsReadable = this.translate.instant('core.unknown');
|
||||
this.maxSubmissionsReadable = this.maxSubmissions < 0 ? undefined : this.translate.instant('core.unknown');
|
||||
this.unlimitedFiles = true;
|
||||
} else {
|
||||
this.maxSubmissionsReadable = String(this.maxSubmissions);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<ion-item text-wrap>
|
||||
{{ 'core.maxsizeandattachments' | translate:{$a: {size: maxSizeReadable, attachments: maxSubmissionsReadable} } }}
|
||||
<span *ngIf="maxSubmissionsReadable">{{ 'core.maxsizeandattachments' | translate:{$a: {size: maxSizeReadable, attachments: maxSubmissionsReadable} } }}</span>
|
||||
<span *ngIf="!maxSubmissionsReadable">{{ 'core.maxfilesize' | translate:{$a: maxSizeReadable} }}</span>
|
||||
<span [core-mark-required]="required" class="core-mark-required"></span>
|
||||
</ion-item>
|
||||
<ion-item text-wrap *ngIf="fileTypes && fileTypes.mimetypes && fileTypes.mimetypes.length">
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { ModalController } from 'ionic-angular';
|
||||
import { Camera, CameraOptions } from '@ionic-native/camera';
|
||||
import { FileEntry } from '@ionic-native/file';
|
||||
import { MediaCapture, MediaFile, CaptureError, CaptureAudioOptions, CaptureVideoOptions } from '@ionic-native/media-capture';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { CoreFileProvider } from '@providers/file';
|
||||
|
@ -25,9 +26,11 @@ import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype';
|
|||
import { CoreTextUtilsProvider } from '@providers/utils/text';
|
||||
import { CoreTimeUtilsProvider } from '@providers/utils/time';
|
||||
import { CoreUtilsProvider } from '@providers/utils/utils';
|
||||
import { CoreWSFileUploadOptions } from '@providers/ws';
|
||||
import { CoreWSFileUploadOptions, CoreWSExternalFile } from '@providers/ws';
|
||||
import { Subject } from 'rxjs';
|
||||
import { CoreApp } from '@providers/app';
|
||||
import { CoreSite } from '@classes/site';
|
||||
import { makeSingleton } from '@singletons/core.singletons';
|
||||
|
||||
/**
|
||||
* File upload options.
|
||||
|
@ -96,7 +99,7 @@ export class CoreFileUploaderProvider {
|
|||
// Currently we are going to compare the order of the files as well.
|
||||
// This function can be improved comparing more fields or not comparing the order.
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if ((a[i].name || a[i].filename) != (b[i].name || b[i].filename)) {
|
||||
if (a[i].name != b[i].name || a[i].filename != b[i].filename) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
@ -104,6 +107,36 @@ export class CoreFileUploaderProvider {
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a certain site allows deleting draft files.
|
||||
*
|
||||
* @param siteId Site Id. If not defined, use current site.
|
||||
* @return Promise resolved with true if can delete.
|
||||
* @since 3.10
|
||||
*/
|
||||
async canDeleteDraftFiles(siteId?: string): Promise<boolean> {
|
||||
try {
|
||||
const site = await this.sitesProvider.getSite(siteId);
|
||||
|
||||
return this.canDeleteDraftFilesInSite(site);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a certain site allows deleting draft files.
|
||||
*
|
||||
* @param site Site. If not defined, use current site.
|
||||
* @return Whether draft files can be deleted.
|
||||
* @since 3.10
|
||||
*/
|
||||
canDeleteDraftFilesInSite(site?: CoreSite): boolean {
|
||||
site = site || this.sitesProvider.getCurrentSite();
|
||||
|
||||
return site.wsAvailable('core_files_delete_draft_files');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the audio recorder application and return information about captured audio clip files.
|
||||
*
|
||||
|
@ -173,6 +206,25 @@ export class CoreFileUploaderProvider {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete draft files.
|
||||
*
|
||||
* @param draftId Draft ID.
|
||||
* @param files Files to delete.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async deleteDraftFiles(draftId: number, files: {filepath: string, filename: string}[], siteId?: string): Promise<void> {
|
||||
const site = await this.sitesProvider.getSite(siteId);
|
||||
|
||||
const params = {
|
||||
draftitemid: draftId,
|
||||
files: files,
|
||||
};
|
||||
|
||||
return site.write('core_files_delete_draft_files', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the upload options for a file taken with the Camera Cordova plugin.
|
||||
*
|
||||
|
@ -215,6 +267,35 @@ export class CoreFileUploaderProvider {
|
|||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of original files and a list of current files, return the list of files to delete.
|
||||
*
|
||||
* @param originalFiles Original files.
|
||||
* @param currentFiles Current files.
|
||||
* @return List of files to delete.
|
||||
*/
|
||||
getFilesToDelete(originalFiles: CoreWSExternalFile[], currentFiles: (CoreWSExternalFile | FileEntry)[])
|
||||
: {filepath: string, filename: string}[] {
|
||||
|
||||
const filesToDelete: {filepath: string, filename: string}[] = [];
|
||||
currentFiles = currentFiles || [];
|
||||
|
||||
originalFiles.forEach((file) => {
|
||||
const stillInList = currentFiles.some((currentFile) => {
|
||||
return (<CoreWSExternalFile> currentFile).fileurl == file.fileurl;
|
||||
});
|
||||
|
||||
if (!stillInList) {
|
||||
filesToDelete.push({
|
||||
filepath: file.filepath,
|
||||
filename: file.filename,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return filesToDelete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the upload options for a file of any type.
|
||||
*
|
||||
|
@ -534,6 +615,47 @@ export class CoreFileUploaderProvider {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of files (either online files or local files), upload the local files to the draft area.
|
||||
* Local files are not deleted from the device after upload.
|
||||
*
|
||||
* @param itemId Draft ID.
|
||||
* @param files List of files.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with the itemId.
|
||||
*/
|
||||
async uploadFiles(itemId: number, files: (CoreWSExternalFile | FileEntry)[], siteId?: string): Promise<void> {
|
||||
siteId = siteId || this.sitesProvider.getCurrentSiteId();
|
||||
|
||||
if (!files || !files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Index the online files by name.
|
||||
const usedNames: {[name: string]: (CoreWSExternalFile | FileEntry)} = {};
|
||||
const filesToUpload: FileEntry[] = [];
|
||||
files.forEach((file) => {
|
||||
const isOnlineFile = (<CoreWSExternalFile> file).filename && !(<FileEntry> file).name;
|
||||
|
||||
if (isOnlineFile) {
|
||||
usedNames[(<CoreWSExternalFile> file).filename.toLowerCase()] = file;
|
||||
} else {
|
||||
filesToUpload.push(<FileEntry> file);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(filesToUpload.map(async (file) => {
|
||||
// Make sure the file name is unique in the area.
|
||||
const name = this.fileProvider.calculateUniqueName(usedNames, file.name);
|
||||
usedNames[name] = file;
|
||||
|
||||
// Now upload the file.
|
||||
const options = this.getFileUploadOptions(file.toURL(), name, undefined, false, 'draft', itemId);
|
||||
|
||||
await this.uploadFile(file.toURL(), options, undefined, siteId);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to a draft area and return the draft ID.
|
||||
*
|
||||
|
@ -615,3 +737,5 @@ export class CoreFileUploaderProvider {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class CoreFileUploader extends makeSingleton(CoreFileUploaderProvider) {}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -34,10 +34,11 @@ export class CoreQuestionBehaviourBaseHandler implements CoreQuestionBehaviourHa
|
|||
* @param component Component the question belongs to.
|
||||
* @param attemptId Attempt ID the question belongs to.
|
||||
* @param question The question.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return New state (or promise resolved with state).
|
||||
*/
|
||||
determineNewState(component: string, attemptId: number, question: any, siteId?: string)
|
||||
determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string)
|
||||
: CoreQuestionState | Promise<CoreQuestionState> {
|
||||
// Return the current state.
|
||||
return this.questionProvider.getState(question.state);
|
||||
|
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
@ -105,9 +107,13 @@ export class CoreQuestionBaseComponent {
|
|||
this.question.select = selectModel;
|
||||
|
||||
// Check which one should be displayed first: the select or the input.
|
||||
const input = questionEl.querySelector('input[type="text"][name*=answer]');
|
||||
this.question.selectFirst =
|
||||
questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(select.outerHTML);
|
||||
if (this.question.settings) {
|
||||
this.question.selectFirst = this.question.settings.unitsleft == '1';
|
||||
} else {
|
||||
const input = questionEl.querySelector('input[type="text"][name*=answer]');
|
||||
this.question.selectFirst =
|
||||
questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(select.outerHTML);
|
||||
}
|
||||
|
||||
return questionEl;
|
||||
}
|
||||
|
@ -159,9 +165,15 @@ export class CoreQuestionBaseComponent {
|
|||
}
|
||||
|
||||
// Check which one should be displayed first: the options or the input.
|
||||
const input = questionEl.querySelector('input[type="text"][name*=answer]');
|
||||
this.question.optionsFirst =
|
||||
questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(radios[0].outerHTML);
|
||||
if (this.question.settings) {
|
||||
this.question.optionsFirst = this.question.settings.unitsleft == '1';
|
||||
} else {
|
||||
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 +213,46 @@ export class CoreQuestionBaseComponent {
|
|||
const questionEl = this.initComponent();
|
||||
|
||||
if (questionEl) {
|
||||
// First search the textarea.
|
||||
const textarea = <HTMLTextAreaElement> questionEl.querySelector('textarea[name*=_answer]');
|
||||
this.question.allowsAttachments = !!questionEl.querySelector('div[id*=filemanager]');
|
||||
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);
|
||||
const answerDraftIdInput = <HTMLInputElement> questionEl.querySelector('input[name*="_answer:itemid"]');
|
||||
|
||||
if (!textarea) {
|
||||
// Textarea not found, we might be in review. Search the answer and the attachments.
|
||||
if (this.question.settings) {
|
||||
this.question.allowsAttachments = this.question.settings.attachments != '0';
|
||||
this.question.allowsAnswerFiles = this.question.settings.responseformat == 'editorfilepicker';
|
||||
this.question.isMonospaced = this.question.settings.responseformat == 'monospaced';
|
||||
this.question.isPlainText = this.question.isMonospaced || this.question.settings.responseformat == 'plain';
|
||||
} else {
|
||||
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.question.allowsAnswerFiles &&
|
||||
this.questionHelper.hasDraftFileUrls(questionEl.innerHTML);
|
||||
|
||||
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 +262,43 @@ 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 (this.question.settings) {
|
||||
this.question.attachmentsMaxFiles = Number(this.question.settings.attachments);
|
||||
this.question.attachmentsAcceptedTypes = this.question.settings.filetypeslist &&
|
||||
this.question.settings.filetypeslist.join(',');
|
||||
}
|
||||
|
||||
if (fileManagerUrl) {
|
||||
const params = CoreUrlUtils.instance.extractUrlParams(fileManagerUrl);
|
||||
const maxBytes = Number(params.maxbytes);
|
||||
const areaMaxBytes = Number(params.areamaxbytes);
|
||||
|
||||
this.question.attachmentsMaxBytes = maxBytes === -1 || areaMaxBytes === -1 ?
|
||||
Math.max(maxBytes, areaMaxBytes) : Math.min(maxBytes, areaMaxBytes);
|
||||
}
|
||||
}
|
||||
|
||||
return questionEl;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -270,6 +338,8 @@ export class CoreQuestionBaseComponent {
|
|||
|
||||
// Set the question text.
|
||||
this.question.text = content.innerHTML;
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -79,9 +79,11 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
@ -91,9 +93,11 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
@ -103,9 +107,11 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param prevAnswers Object with the previous question answers.
|
||||
* @param newAnswers Object with the new question answers.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -115,10 +121,13 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler {
|
|||
* @param question Question.
|
||||
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
|
||||
* @param offline Whether the data should be saved in offline.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @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> {
|
||||
prepareAnswers(question: any, answers: any, offline: boolean, component: string, componentId: string | number, siteId?: string)
|
||||
: void | Promise<any> {
|
||||
// Nothing to do.
|
||||
}
|
||||
|
||||
|
|
|
@ -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.",
|
||||
"errorembeddedfilesnotsupportedinsite": "Your site doesn't support editing embedded files yet.",
|
||||
"errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.",
|
||||
"feedback": "Feedback",
|
||||
"howtodraganddrop": "Tap to select then tap to drop.",
|
||||
|
|
|
@ -36,10 +36,11 @@ export interface CoreQuestionBehaviourHandler extends CoreDelegateHandler {
|
|||
* @param component Component the question belongs to.
|
||||
* @param attemptId Attempt ID the question belongs to.
|
||||
* @param question The question.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return State (or promise resolved with state).
|
||||
*/
|
||||
determineNewState?(component: string, attemptId: number, question: any, siteId?: string)
|
||||
determineNewState?(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string)
|
||||
: CoreQuestionState | Promise<CoreQuestionState>;
|
||||
|
||||
/**
|
||||
|
@ -74,15 +75,16 @@ export class CoreQuestionBehaviourDelegate extends CoreDelegate {
|
|||
* @param component Component the question belongs to.
|
||||
* @param attemptId Attempt ID the question belongs to.
|
||||
* @param question The question.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with state.
|
||||
*/
|
||||
determineNewState(behaviour: string, component: string, attemptId: number, question: any, siteId?: string)
|
||||
: Promise<CoreQuestionState> {
|
||||
determineNewState(behaviour: string, component: string, attemptId: number, question: any, componentId: string | number,
|
||||
siteId?: string): Promise<CoreQuestionState> {
|
||||
behaviour = this.questionDelegate.getBehaviourForQuestion(question, behaviour);
|
||||
|
||||
return Promise.resolve(this.executeFunctionOnEnabled(behaviour, 'determineNewState',
|
||||
[component, attemptId, question, siteId]));
|
||||
[component, attemptId, question, componentId, siteId]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -62,9 +62,11 @@ export interface CoreQuestionHandler extends CoreDelegateHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse?(question: any, answers: any): number;
|
||||
isCompleteResponse?(question: any, answers: any, component: string, componentId: string | number): number;
|
||||
|
||||
/**
|
||||
* Check if a student has provided enough of an answer for the question to be graded automatically,
|
||||
|
@ -72,9 +74,11 @@ export interface CoreQuestionHandler extends CoreDelegateHandler {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse?(question: any, answers: any): number;
|
||||
isGradableResponse?(question: any, answers: any, component: string, componentId: string | number): number;
|
||||
|
||||
/**
|
||||
* Check if two responses are the same.
|
||||
|
@ -84,7 +88,7 @@ export interface CoreQuestionHandler extends CoreDelegateHandler {
|
|||
* @param newAnswers Object with the new question answers.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse?(question: any, prevAnswers: any, newAnswers: any): boolean;
|
||||
isSameResponse?(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean;
|
||||
|
||||
/**
|
||||
* Prepare and add to answers the data to send to server based in the input. Return promise if async.
|
||||
|
@ -92,10 +96,13 @@ export interface CoreQuestionHandler extends CoreDelegateHandler {
|
|||
* @param question Question.
|
||||
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
|
||||
* @param offline Whether the data should be saved in offline.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @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>;
|
||||
prepareAnswers?(question: any, answers: any, offline: boolean, component: string, componentId: string | number,
|
||||
siteId?: string): void | Promise<any>;
|
||||
|
||||
/**
|
||||
* Validate if an offline sequencecheck is valid compared with the online one.
|
||||
|
@ -115,6 +122,40 @@ export interface CoreQuestionHandler extends CoreDelegateHandler {
|
|||
* @return List of URLs.
|
||||
*/
|
||||
getAdditionalDownloadableFiles?(question: any, usageId: number): string[];
|
||||
|
||||
/**
|
||||
* Clear temporary data after the data has been saved.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return If async, promise resolved when done.
|
||||
*/
|
||||
clearTmpData?(question: any, component: string, componentId: string | number): void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete any stored data for the question.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return If async, promise resolved when done.
|
||||
*/
|
||||
deleteOfflineData?(question: any, component: string, componentId: string | number, siteId?: string): void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Prepare data to send when performing a synchronization.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param answers Answers of the question, without the prefix.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return If async, promise resolved when done.
|
||||
*/
|
||||
prepareSyncData?(question: any, answers: {[name: string]: any}, component: string, componentId: string | number,
|
||||
siteId?: string): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -196,12 +237,14 @@ export class CoreQuestionDelegate extends CoreDelegate {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||
*/
|
||||
isCompleteResponse(question: any, answers: any): number {
|
||||
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
const type = this.getTypeName(question);
|
||||
|
||||
return this.executeFunctionOnEnabled(type, 'isCompleteResponse', [question, answers]);
|
||||
return this.executeFunctionOnEnabled(type, 'isCompleteResponse', [question, answers, component, componentId]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -210,12 +253,14 @@ export class CoreQuestionDelegate extends CoreDelegate {
|
|||
*
|
||||
* @param question The question.
|
||||
* @param answers Object with the question answers (without prefix).
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
|
||||
*/
|
||||
isGradableResponse(question: any, answers: any): number {
|
||||
isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
|
||||
const type = this.getTypeName(question);
|
||||
|
||||
return this.executeFunctionOnEnabled(type, 'isGradableResponse', [question, answers]);
|
||||
return this.executeFunctionOnEnabled(type, 'isGradableResponse', [question, answers, component, componentId]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -226,10 +271,10 @@ export class CoreQuestionDelegate extends CoreDelegate {
|
|||
* @param newAnswers Object with the new question answers.
|
||||
* @return Whether they're the same.
|
||||
*/
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
|
||||
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
|
||||
const type = this.getTypeName(question);
|
||||
|
||||
return this.executeFunctionOnEnabled(type, 'isSameResponse', [question, prevAnswers, newAnswers]);
|
||||
return this.executeFunctionOnEnabled(type, 'isSameResponse', [question, prevAnswers, newAnswers, component, componentId]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -248,13 +293,17 @@ export class CoreQuestionDelegate extends CoreDelegate {
|
|||
* @param question Question.
|
||||
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
|
||||
* @param offline Whether the data should be saved in offline.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved when data has been prepared.
|
||||
*/
|
||||
prepareAnswersForQuestion(question: any, answers: any, offline: boolean, siteId?: string): Promise<any> {
|
||||
prepareAnswersForQuestion(question: any, answers: any, offline: boolean, component: string, componentId: string | number,
|
||||
siteId?: string): Promise<any> {
|
||||
const type = this.getTypeName(question);
|
||||
|
||||
return Promise.resolve(this.executeFunctionOnEnabled(type, 'prepareAnswers', [question, answers, offline, siteId]));
|
||||
return Promise.resolve(this.executeFunctionOnEnabled(type, 'prepareAnswers',
|
||||
[question, answers, offline, component, componentId, siteId]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -282,4 +331,50 @@ export class CoreQuestionDelegate extends CoreDelegate {
|
|||
|
||||
return this.executeFunctionOnEnabled(type, 'getAdditionalDownloadableFiles', [question, usageId]) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear temporary data after the data has been saved.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return If async, promise resolved when done.
|
||||
*/
|
||||
clearTmpData(question: any, component: string, componentId: string | number): void | Promise<void> {
|
||||
const type = this.getTypeName(question);
|
||||
|
||||
return this.executeFunctionOnEnabled(type, 'clearTmpData', [question, component, componentId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear temporary data after the data has been saved.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return If async, promise resolved when done.
|
||||
*/
|
||||
deleteOfflineData(question: any, component: string, componentId: string | number, siteId?: string): void | Promise<void> {
|
||||
const type = this.getTypeName(question);
|
||||
|
||||
return this.executeFunctionOnEnabled(type, 'deleteOfflineData', [question, component, componentId, siteId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare data to send when performing a synchronization.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param answers Answers of the question, without the prefix.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return If async, promise resolved when done.
|
||||
*/
|
||||
prepareSyncData?(question: any, answers: {[name: string]: any}, component: string, componentId: string | number,
|
||||
siteId?: string): void | Promise<void> {
|
||||
const type = this.getTypeName(question);
|
||||
|
||||
return this.executeFunctionOnEnabled(type, 'prepareSyncData', [question, answers, component, componentId, siteId]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,9 +13,12 @@
|
|||
// limitations under the License.
|
||||
|
||||
import { Injectable, EventEmitter } from '@angular/core';
|
||||
import { FileEntry } from '@ionic-native/file';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { CoreFile } from '@providers/file';
|
||||
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';
|
||||
|
@ -59,6 +62,41 @@ export class CoreQuestionHelperProvider {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear questions temporary data after the data has been saved.
|
||||
*
|
||||
* @param questions The list of questions.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async clearTmpData(questions: any[], component: string, componentId: string | number): Promise<void> {
|
||||
questions = questions || [];
|
||||
|
||||
await Promise.all(questions.map(async (question) => {
|
||||
await this.questionDelegate.clearTmpData(question, component, componentId);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete files stored for a question.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async deleteStoredQuestionFiles(question: any, component: string, componentId: string | number, siteId?: string)
|
||||
: Promise<void> {
|
||||
|
||||
const questionComponentId = this.questionProvider.getQuestionComponentId(question, componentId);
|
||||
const folderPath = this.questionProvider.getQuestionFolder(question.type, component, questionComponentId, siteId);
|
||||
|
||||
// Ignore errors, maybe the folder doesn't exist.
|
||||
await this.utils.ignoreErrors(CoreFile.instance.removeDir(folderPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract question behaviour submit buttons from the question's HTML and add them to "behaviourButtons" property.
|
||||
* The buttons aren't deleted from the content because all the im-controls block will be removed afterwards.
|
||||
|
@ -421,6 +459,41 @@ 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 files stored for a question.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with the files.
|
||||
*/
|
||||
getStoredQuestionFiles(question: any, component: string, componentId: string | number, siteId?: string): Promise<FileEntry[]> {
|
||||
const questionComponentId = this.questionProvider.getQuestionComponentId(question, componentId);
|
||||
const folderPath = this.questionProvider.getQuestionFolder(question.type, component, questionComponentId, siteId);
|
||||
|
||||
return CoreFile.instance.getDirectoryContents(folderPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation error message from a question HTML if it's there.
|
||||
*
|
||||
|
@ -521,7 +594,7 @@ export class CoreQuestionHelperProvider {
|
|||
|
||||
if (!component) {
|
||||
component = CoreQuestionProvider.COMPONENT;
|
||||
componentId = question.id;
|
||||
componentId = question.number;
|
||||
}
|
||||
|
||||
urls.push(...this.questionDelegate.getAdditionalDownloadableFiles(question, usageId));
|
||||
|
@ -552,15 +625,19 @@ export class CoreQuestionHelperProvider {
|
|||
* @param questions The list of questions.
|
||||
* @param answers The input data.
|
||||
* @param offline True if data should be saved in offline.
|
||||
* @param component The component the question is related to.
|
||||
* @param componentId Component ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with answers to send to server.
|
||||
*/
|
||||
prepareAnswers(questions: any[], answers: any, offline?: boolean, siteId?: string): Promise<any> {
|
||||
prepareAnswers(questions: any[], answers: any, offline: boolean, component: string, componentId: string | number,
|
||||
siteId?: string): Promise<any> {
|
||||
const promises = [];
|
||||
|
||||
questions = questions || [];
|
||||
questions.forEach((question) => {
|
||||
promises.push(this.questionDelegate.prepareAnswersForQuestion(question, answers, offline, siteId));
|
||||
promises.push(this.questionDelegate.prepareAnswersForQuestion(question, answers, offline, component, componentId,
|
||||
siteId));
|
||||
});
|
||||
|
||||
return this.utils.allPromises(promises).then(() => {
|
||||
|
|
|
@ -13,10 +13,13 @@
|
|||
// limitations under the License.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CoreFile } from '@providers/file';
|
||||
import { CoreLoggerProvider } from '@providers/logger';
|
||||
import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites';
|
||||
import { CoreTextUtils } from '@providers/utils/text';
|
||||
import { CoreTimeUtilsProvider } from '@providers/utils/time';
|
||||
import { CoreUtilsProvider } from '@providers/utils/utils';
|
||||
import { makeSingleton } from '@singletons/core.singletons';
|
||||
|
||||
/**
|
||||
* An object to represent a question state.
|
||||
|
@ -413,6 +416,35 @@ export class CoreQuestionProvider {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a question and a componentId, return a componentId that is unique for the question.
|
||||
*
|
||||
* @param question Question.
|
||||
* @param componentId Component ID.
|
||||
* @return Question component ID.
|
||||
*/
|
||||
getQuestionComponentId(question: any, componentId: string | number): string {
|
||||
return componentId + '_' + question.number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the folder where to store files for an offline question.
|
||||
*
|
||||
* @param type Question type.
|
||||
* @param component Component the question is related to.
|
||||
* @param componentId Question component ID, returned by getQuestionComponentId.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Folder path.
|
||||
*/
|
||||
getQuestionFolder(type: string, component: string, componentId: string, siteId?: string): string {
|
||||
siteId = siteId || this.sitesProvider.getCurrentSiteId();
|
||||
|
||||
const siteFolderPath = CoreFile.instance.getSiteFolder(siteId);
|
||||
const questionFolderPath = 'offlinequestion/' + type + '/' + component + '/' + componentId;
|
||||
|
||||
return CoreTextUtils.instance.concatenatePaths(siteFolderPath, questionFolderPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the question slot from a question name.
|
||||
*
|
||||
|
@ -612,3 +644,5 @@ export class CoreQuestionProvider {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class CoreQuestion extends makeSingleton(CoreQuestionProvider) {}
|
||||
|
|
|
@ -143,6 +143,7 @@
|
|||
"loadmore": "Load more",
|
||||
"location": "Location",
|
||||
"lostconnection": "Your authentication token is invalid or has expired. You will have to reconnect to the site.",
|
||||
"maxfilesize": "Maximum size for new files: {{$a}}",
|
||||
"maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}",
|
||||
"min": "min",
|
||||
"mins": "mins",
|
||||
|
|
|
@ -1114,10 +1114,8 @@ export class CoreFileProvider {
|
|||
// Get existing files in the folder.
|
||||
return this.getDirectoryContents(dirPath).then((entries) => {
|
||||
const files = {};
|
||||
let num = 1,
|
||||
fileNameWithoutExtension = this.mimeUtils.removeExtension(fileName),
|
||||
extension = this.mimeUtils.getFileExtension(fileName) || defaultExt,
|
||||
newName;
|
||||
let fileNameWithoutExtension = this.mimeUtils.removeExtension(fileName);
|
||||
let extension = this.mimeUtils.getFileExtension(fileName) || defaultExt;
|
||||
|
||||
// Clean the file name.
|
||||
fileNameWithoutExtension = this.textUtils.removeSpecialCharactersForFiles(
|
||||
|
@ -1135,26 +1133,40 @@ export class CoreFileProvider {
|
|||
extension = '';
|
||||
}
|
||||
|
||||
newName = fileNameWithoutExtension + extension;
|
||||
if (typeof files[newName.toLowerCase()] == 'undefined') {
|
||||
// No file with the same name.
|
||||
return newName;
|
||||
} else {
|
||||
// Repeated name. Add a number until we find a free name.
|
||||
do {
|
||||
newName = fileNameWithoutExtension + '(' + num + ')' + extension;
|
||||
num++;
|
||||
} while (typeof files[newName.toLowerCase()] != 'undefined');
|
||||
|
||||
// Ask the user what he wants to do.
|
||||
return newName;
|
||||
}
|
||||
return this.calculateUniqueName(files, fileNameWithoutExtension + extension);
|
||||
}).catch(() => {
|
||||
// Folder doesn't exist, name is unique. Clean it and return it.
|
||||
return this.textUtils.removeSpecialCharactersForFiles(this.textUtils.decodeURIComponent(fileName));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a file name and a set of already used names, calculate a unique name.
|
||||
*
|
||||
* @param usedNames Object with names already used as keys.
|
||||
* @param name Name to check.
|
||||
* @return Unique name.
|
||||
*/
|
||||
calculateUniqueName(usedNames: {[name: string]: any}, name: string): string {
|
||||
if (typeof usedNames[name.toLowerCase()] == 'undefined') {
|
||||
// No file with the same name.
|
||||
return name;
|
||||
} else {
|
||||
// Repeated name. Add a number until we find a free name.
|
||||
const nameWithoutExtension = this.mimeUtils.removeExtension(name);
|
||||
let extension = this.mimeUtils.getFileExtension(name);
|
||||
let num = 1;
|
||||
extension = extension ? '.' + extension : '';
|
||||
|
||||
do {
|
||||
name = nameWithoutExtension + '(' + num + ')' + extension;
|
||||
num++;
|
||||
} while (typeof usedNames[name.toLowerCase()] != 'undefined');
|
||||
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove app temporary folder.
|
||||
*
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
This files describes API changes in the Moodle Mobile app,
|
||||
information provided here is intended especially for developers.
|
||||
|
||||
=== 3.9.3 ===
|
||||
|
||||
- In the core-attachments component, passing a -1 as maxSize or maxSubmissions used to mean "unknown limit". Now -1 means unlimited.
|
||||
|
||||
=== 3.8.3 ===
|
||||
|
||||
- CoreFileProvider.writeFileDataInFile has been deprecated. Please use CoreFileHelperProvider.writeFileDataInFile instead.
|
||||
|
|
Loading…
Reference in New Issue