/
githubmirror
/
edx-platform
Обзор
Документация
Войти
/
githubmirror
/
edx-platform
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
release-ulmo
xmodule/js/src/capa/display.js
1 358 строк
65 KB
ihor-romaniuk
fix: visual and progress score display enhancement
17 сен 2025, 21:43
17 сен 2025, 21:43
30f23f3
Код
Авторство
О чём код?
/* global MathJax, Collapsible, interpolate, JavascriptLoader, Logger, CodeMirror */ // Note: this code was originally converted from CoffeeScript, and thus follows some // coding conventions that are discouraged by eslint. Some warnings have been suppressed // to avoid substantial rewriting of the code. Allow the eslint suppressions to exceed // the max line length of 120. /* eslint max-len: ["error", 120, { "ignoreComments": true }] */ (function() { 'use strict'; var indexOfHelper = [].indexOf || function(item) { var i, len; for (i = 0, len = this.length; i < len; i++) { if (i in this && this[i] === item) { return i; } } return -1; }; this.Problem = (function() { function Problem(element) { var that = this; this.hint_button = function() { return Problem.prototype.hint_button.apply(that, arguments); }; this.enableSubmitButtonAfterTimeout = function() { return Problem.prototype.enableSubmitButtonAfterTimeout.apply(that, arguments); }; this.enableSubmitButtonAfterResponse = function() { return Problem.prototype.enableSubmitButtonAfterResponse.apply(that, arguments); }; this.enableSubmitButton = function(enable, changeText) { if (changeText === null || changeText === undefined) { changeText = true; // eslint-disable-line no-param-reassign } return Problem.prototype.enableSubmitButton.apply(that, arguments); }; this.disableAllButtonsWhileRunning = function( operationCallback, isFromCheckOperation // eslint-disable-line no-unused-vars ) { return Problem.prototype.disableAllButtonsWhileRunning.apply(that, arguments); }; this.submitAnswersAndSubmitButton = function(bind) { if (bind === null || bind === undefined) { bind = false; // eslint-disable-line no-param-reassign } return Problem.prototype.submitAnswersAndSubmitButton.apply(that, arguments); }; this.refreshAnswers = function() { return Problem.prototype.refreshAnswers.apply(that, arguments); }; this.updateMathML = function(jax, el) { // eslint-disable-line no-unused-vars return Problem.prototype.updateMathML.apply(that, arguments); }; this.refreshMath = function(event, el) { // eslint-disable-line no-unused-vars return Problem.prototype.refreshMath.apply(that, arguments); }; this.save_internal = function() { return Problem.prototype.save_internal.apply(that, arguments); }; this.save = function() { return Problem.prototype.save.apply(that, arguments); }; this.gentle_alert = function(msg) { // eslint-disable-line no-unused-vars return Problem.prototype.gentle_alert.apply(that, arguments); }; this.clear_all_notifications = function() { return Problem.prototype.clear_all_notifications.apply(that, arguments); }; this.show = function() { return Problem.prototype.show.apply(that, arguments); }; this.reset_internal = function() { return Problem.prototype.reset_internal.apply(that, arguments); }; this.reset = function() { return Problem.prototype.reset.apply(that, arguments); }; this.get_sr_status = function(contents) { // eslint-disable-line no-unused-vars return Problem.prototype.get_sr_status.apply(that, arguments); }; this.submit_internal = function() { return Problem.prototype.submit_internal.apply(that, arguments); }; this.submit = function() { return Problem.prototype.submit.apply(that, arguments); }; this.submit_fd = function() { return Problem.prototype.submit_fd.apply(that, arguments); }; this.focus_on_save_notification = function() { return Problem.prototype.focus_on_save_notification.apply(that, arguments); }; this.focus_on_hint_notification = function() { return Problem.prototype.focus_on_hint_notification.apply(that, arguments); }; this.focus_on_submit_notification = function() { return Problem.prototype.focus_on_submit_notification.apply(that, arguments); }; this.focus_on_notification = function(type) { // eslint-disable-line no-unused-vars return Problem.prototype.focus_on_notification.apply(that, arguments); }; this.scroll_to_problem_meta = function() { return Problem.prototype.scroll_to_problem_meta.apply(that, arguments); }; this.submit_save_waitfor = function(callback) { // eslint-disable-line no-unused-vars return Problem.prototype.submit_save_waitfor.apply(that, arguments); }; this.setupInputTypes = function() { return Problem.prototype.setupInputTypes.apply(that, arguments); }; this.poll = function(prevTimeout, focusCallback // eslint-disable-line no-unused-vars ) { return Problem.prototype.poll.apply(that, arguments); }; this.queueing = function(focusCallback) { // eslint-disable-line no-unused-vars return Problem.prototype.queueing.apply(that, arguments); }; this.forceUpdate = function(response) { // eslint-disable-line no-unused-vars return Problem.prototype.forceUpdate.apply(that, arguments); }; this.updateProgress = function(response) { // eslint-disable-line no-unused-vars return Problem.prototype.updateProgress.apply(that, arguments); }; this.renderProgressState = function() { return Problem.prototype.renderProgressState.apply(that, arguments); }; this.bind = function() { return Problem.prototype.bind.apply(that, arguments); }; this.el = $(element).find('.problems-wrapper'); this.id = this.el.data('problem-id'); this.element_id = this.el.attr('id'); this.url = this.el.data('url'); this.content = this.el.data('content'); // has_timed_out and has_response are used to ensure that // we wait a minimum of ~ 1s before transitioning the submit // button from disabled to enabled this.has_timed_out = false; this.has_response = false; this.render(this.content); } Problem.prototype.$ = function(selector) { return $(selector, this.el); }; Problem.prototype.bind = function() { var problemPrefix, that = this; if (typeof MathJax !== 'undefined' && MathJax !== null) { this.el.find('.problem > div').each(function(index, element) { return MathJax.Hub.Queue(['Typeset', MathJax.Hub, element]); }); } if (window.hasOwnProperty('update_schematics')) { window.update_schematics(); } problemPrefix = this.element_id.replace(/problem_/, ''); this.inputs = this.$('[id^="input_' + problemPrefix + '_"]'); this.$('div.action button').click(this.refreshAnswers); this.reviewButton = this.$('.notification-btn.review-btn'); this.reviewButton.click(this.scroll_to_problem_meta); this.submitButton = this.$('.action .submit'); this.submitButtonLabel = this.$('.action .submit .submit-label'); this.submitButtonSubmitText = this.submitButtonLabel.text(); this.submitButtonSubmittingText = this.submitButton.data('submitting'); this.submitButton.click(this.submit_fd); this.hintButton = this.$('.action .hint-button'); this.hintButton.click(this.hint_button); this.resetButton = this.$('.action .reset'); this.resetButton.click(this.reset); this.showButton = this.$('.action .show'); this.showButton.click(this.show); this.saveButton = this.$('.action .save'); this.saveNotification = this.$('.notification-save'); this.showAnswerNotification = this.$('.notification-show-answer'); this.saveButton.click(this.save); this.gentleAlertNotification = this.$('.notification-gentle-alert'); this.submitNotification = this.$('.notification-submit'); // Accessibility helper for sighted keyboard users to show <clarification> tooltips on focus: this.$('.clarification').focus(function(ev) { var icon; icon = $(ev.target).children('i'); return window.globalTooltipManager.openTooltip(icon); }); this.$('.clarification').blur(function() { return window.globalTooltipManager.hide(); }); this.$('.review-btn').focus(function(ev) { return $(ev.target).removeClass('sr'); }); this.$('.review-btn').blur(function(ev) { return $(ev.target).addClass('sr'); }); this.bindResetCorrectness(); if (this.submitButton.length) { this.submitAnswersAndSubmitButton(true); } Collapsible.setCollapsibles(this.el); this.$('input.math').keyup(this.refreshMath); if (typeof MathJax !== 'undefined' && MathJax !== null) { this.$('input.math').each(function(index, element) { return MathJax.Hub.Queue([that.refreshMath, null, element]); }); } }; Problem.prototype.renderProgressState = function() { var graded, progress, progressTemplate, curScore, totalScore, attemptsUsed; curScore = this.el.data('problem-score'); totalScore = this.el.data('problem-total-possible'); attemptsUsed = this.el.data('attempts-used'); graded = this.el.data('graded'); // The problem is ungraded if it's explicitly marked as such, or if the total possible score is 0 if (graded === 'True' && totalScore !== 0) { graded = true; } else { graded = false; } if (curScore === undefined || totalScore === undefined) { // Render an empty string. progressTemplate = ''; } else if (curScore === null || curScore === 'None') { // Render 'x point(s) possible (un/graded, results hidden)' if no current score provided. if (graded) { progressTemplate = ngettext( // Translators: {num_points} is the number of points possible (examples: 1, 3, 10).; '{num_points} point possible (graded, results hidden)', '{num_points} points possible (graded, results hidden)', totalScore ); } else { progressTemplate = ngettext( // Translators: {num_points} is the number of points possible (examples: 1, 3, 10).; '{num_points} point possible (ungraded, results hidden)', '{num_points} points possible (ungraded, results hidden)', totalScore ); } } else if ((attemptsUsed === 0 || totalScore === 0) && curScore === 0) { // Render 'x point(s) possible' if student has not yet attempted question // But if staff has overridden score to a non-zero number, show it if (graded) { progressTemplate = ngettext( // Translators: {num_points} is the number of points possible (examples: 1, 3, 10).; '{num_points} point possible (graded)', '{num_points} points possible (graded)', totalScore ); } else { progressTemplate = ngettext( // Translators: {num_points} is the number of points possible (examples: 1, 3, 10).; '{num_points} point possible (ungraded)', '{num_points} points possible (ungraded)', totalScore ); } } else { // Render 'x/y point(s)' if student has attempted question if (graded) { progressTemplate = ngettext( // This comment needs to be on one line to be properly scraped for the translators. // Translators: {earned} is the number of points earned. {possible} is the total number of points (examples: 0/1, 1/1, 2/3, 5/10). The total number of points will always be at least 1. We pluralize based on the total number of points (example: 0/1 point; 1/2 points); '{earned}/{possible} point (graded)', '{earned}/{possible} points (graded)', totalScore ); } else { progressTemplate = ngettext( // This comment needs to be on one line to be properly scraped for the translators. // Translators: {earned} is the number of points earned. {possible} is the total number of points (examples: 0/1, 1/1, 2/3, 5/10). The total number of points will always be at least 1. We pluralize based on the total number of points (example: 0/1 point; 1/2 points); '{earned}/{possible} point (ungraded)', '{earned}/{possible} points (ungraded)', totalScore ); } } progress = edx.StringUtils.interpolate( progressTemplate, { earned: curScore, num_points: totalScore, possible: totalScore } ); return this.$('.problem-progress').text(progress); }; Problem.prototype.updateProgress = function(response) { if (response.progress_changed) { this.el.data('problem-score', this.convertToFloat(response.current_score)); this.el.data('problem-total-possible', this.convertToFloat(response.total_possible)); this.el.data('attempts-used', response.attempts_used); this.el.trigger('progressChanged'); } return this.renderProgressState(); }; Problem.prototype.convertToFloat = function(num) { if (typeof num !== 'number' || !Number.isInteger(num)) { return num; } return num.toFixed(1); }; Problem.prototype.forceUpdate = function(response) { this.el.data('problem-score', response.current_score); this.el.data('problem-total-possible', response.total_possible); this.el.data('attempts-used', response.attempts_used); this.el.trigger('progressChanged'); return this.renderProgressState(); }; Problem.prototype.queueing = function(focusCallback) { var that = this; this.queued_items = this.$('.xqueue'); this.num_queued_items = this.queued_items.length; if (this.num_queued_items > 0) { if (window.queuePollerID) { // Only one poller 'thread' per Problem window.clearTimeout(window.queuePollerID); } window.queuePollerID = window.setTimeout(function() { return that.poll(1000, focusCallback); }, 1000); } }; Problem.prototype.poll = function(previousTimeout, focusCallback) { var that = this; return $.postWithPrefix('' + this.url + '/problem_get', function(response) { var newTimeout; // If queueing status changed, then render that.new_queued_items = $(response.html).find('.xqueue'); if (that.new_queued_items.length !== that.num_queued_items) { edx.HtmlUtils.setHtml(that.el, edx.HtmlUtils.HTML(response.html)).promise().done(function() { // eslint-disable-next-line no-void return typeof focusCallback === 'function' ? focusCallback() : void 0; }); JavascriptLoader.executeModuleScripts(that.el, function() { that.setupInputTypes(); that.bind(); }); } that.num_queued_items = that.new_queued_items.length; if (that.num_queued_items === 0) { that.forceUpdate(response); delete window.queuePollerID; } else { newTimeout = previousTimeout * 2; // if the timeout is greather than 1 minute if (newTimeout >= 60000) { delete window.queuePollerID; that.gentle_alert( gettext('The grading process is still running. Refresh the page to see updates.') ); } else { window.queuePollerID = window.setTimeout(function() { return that.poll(newTimeout, focusCallback); }, newTimeout); } } }); }; /** * Use this if you want to make an ajax call on the input type object * static method so you don't have to instantiate a Problem in order to use it * * Input: * url: the AJAX url of the problem * inputId: the inputId of the input you would like to make the call on * NOTE: the id is the ${id} part of "input_${id}" during rendering * If this function is passed the entire prefixed id, the backend may have trouble * finding the correct input * dispatch: string that indicates how this data should be handled by the inputtype * data: dictionary of data to send to the server * callback: the function that will be called once the AJAX call has been completed. * It will be passed a response object */ Problem.inputAjax = function(url, inputId, dispatch, data, callback) { data.dispatch = dispatch; // eslint-disable-line no-param-reassign data.input_id = inputId; // eslint-disable-line no-param-reassign return $.postWithPrefix('' + url + '/input_ajax', data, callback); }; Problem.prototype.render = function(content, focusCallback) { var that = this; if (content) { edx.HtmlUtils.setHtml(this.el, edx.HtmlUtils.HTML(content)); return JavascriptLoader.executeModuleScripts(this.el, function() { that.setupInputTypes(); that.bind(); that.queueing(focusCallback); that.renderProgressState(); // eslint-disable-next-line no-void return typeof focusCallback === 'function' ? focusCallback() : void 0; }); } else { return $.postWithPrefix('' + this.url + '/problem_get', function(response) { edx.HtmlUtils.setHtml(that.el, edx.HtmlUtils.HTML(response.html)); return JavascriptLoader.executeModuleScripts(that.el, function() { that.setupInputTypes(); that.bind(); that.queueing(); return that.forceUpdate(response); }); }); } }; Problem.prototype.setupInputTypes = function() { var that = this; this.inputtypeDisplays = {}; return this.el.find('.capa_inputtype').each(function(index, inputtype) { var classes, cls, id, setupMethod, i, len, results; classes = $(inputtype).attr('class').split(' '); id = $(inputtype).attr('id'); results = []; for (i = 0, len = classes.length; i < len; i++) { cls = classes[i]; setupMethod = that.inputtypeSetupMethods[cls]; if (setupMethod != null) { results.push(that.inputtypeDisplays[id] = setupMethod(inputtype)); } else { // eslint-disable-next-line no-void results.push(void 0); } } return results; }); }; /** * If some function wants to be called before sending the answer to the * server, give it a chance to do so. * * submit_save_waitfor allows the callee to send alerts if the user's input is * invalid. To do so, the callee must throw an exception named "WaitforException". * This and any other errors or exceptions that arise from the callee are rethrown * and abort the submission. * * In order to use this feature, add a 'data-waitfor' attribute to the input, * and specify the function to be called by the submit button before sending off @answers */ Problem.prototype.submit_save_waitfor = function(callback) { var flag, inp, i, len, ref, that = this; flag = false; ref = this.inputs; for (i = 0, len = ref.length; i < len; i++) { inp = ref[i]; if ($(inp).is('input[waitfor]')) { try { $(inp).data('waitfor')(function() { that.refreshAnswers(); return callback(); }); } catch (e) { if (e.name === 'Waitfor Exception') { alert(e.message); // eslint-disable-line no-alert } else { alert( // eslint-disable-line no-alert gettext('Could not grade your answer. The submission was aborted.') ); } throw e; } flag = true; } else { flag = false; } } return flag; }; // Scroll to problem metadata and next focus is problem input Problem.prototype.scroll_to_problem_meta = function() { var questionTitle; questionTitle = this.$('.problem-header'); if (questionTitle.length > 0) { $('html, body').animate({ scrollTop: questionTitle.offset().top }, 500); questionTitle.focus(); } }; Problem.prototype.focus_on_notification = function(type) { var notification; notification = this.$('.notification-' + type); if (notification.length > 0) { notification.focus(); } }; Problem.prototype.focus_on_submit_notification = function() { this.focus_on_notification('submit'); }; Problem.prototype.focus_on_hint_notification = function(hintIndex) { this.$('.notification-hint .notification-message > ol > li.hint-index-' + hintIndex).focus(); }; Problem.prototype.focus_on_save_notification = function() { this.focus_on_notification('save'); }; /** * 'submit_fd' uses FormData to allow file submissions in the 'problem_check' dispatch, * in addition to simple querystring-based answers * * NOTE: The dispatch 'problem_check' is being singled out for the use of FormData; * maybe preferable to consolidate all dispatches to use FormData */ Problem.prototype.submit_fd = function() { var abortSubmission, error, errorHtml, errors, fd, fileNotSelected, fileTooLarge, maxFileSize, requiredFilesNotSubmitted, settings, timeoutId, unallowedFileSubmitted, i, len, that = this; // If there are no file inputs in the problem, we can fall back on submit. if (this.el.find('input:file').length === 0) { this.submit(); return; } this.enableSubmitButton(false); if (!window.FormData) { alert(gettext('Submission aborted! Sorry, your browser does not support file uploads. If you can, please use Chrome or Safari which have been verified to support file uploads.')); // eslint-disable-line max-len, no-alert this.enableSubmitButton(true); return; } timeoutId = this.enableSubmitButtonAfterTimeout(); fd = new FormData(); // Sanity checks on submission maxFileSize = 4 * 1000 * 1000; fileTooLarge = false; fileNotSelected = false; requiredFilesNotSubmitted = false; unallowedFileSubmitted = false; errors = []; this.inputs.each(function(index, element) { var allowedFiles, file, maxSize, requiredFiles, loopI, loopLen, ref; if (element.type === 'file') { requiredFiles = $(element).data('required_files'); allowedFiles = $(element).data('allowed_files'); ref = element.files; for (loopI = 0, loopLen = ref.length; loopI < loopLen; loopI++) { file = ref[loopI]; if (allowedFiles.length !== 0 && indexOfHelper.call(allowedFiles, file.name) < 0) { unallowedFileSubmitted = true; errors.push(edx.StringUtils.interpolate( gettext('You submitted {filename}; only {allowedFiles} are allowed.'), { filename: file.name, allowedFiles: allowedFiles } )); } if (indexOfHelper.call(requiredFiles, file.name) >= 0) { requiredFiles.splice(requiredFiles.indexOf(file.name), 1); } if (file.size > maxFileSize) { fileTooLarge = true; maxSize = maxFileSize / (1000 * 1000); errors.push(edx.StringUtils.interpolate( gettext('Your file {filename} is too large (max size: {maxSize}MB).'), { filename: file.name, maxSize: maxSize } )); } fd.append(element.id, file); // xss-lint: disable=javascript-jquery-append } if (element.files.length === 0) { fileNotSelected = true; // In case we want to allow submissions with no file fd.append(element.id, ''); // xss-lint: disable=javascript-jquery-append } if (requiredFiles.length !== 0) { requiredFilesNotSubmitted = true; errors.push(edx.StringUtils.interpolate( gettext('You did not submit the required files: {requiredFiles}.'), { requiredFiles: requiredFiles } )); } } else { fd.append(element.id, element.value); // xss-lint: disable=javascript-jquery-append } }); if (fileNotSelected) { errors.push(gettext('You did not select any files to submit.')); } errorHtml = ''; for (i = 0, len = errors.length; i < len; i++) { error = errors[i]; errorHtml = edx.HtmlUtils.joinHtml( errorHtml, edx.HtmlUtils.interpolateHtml(edx.HtmlUtils.HTML('<li>{error}</li>'), {error: error}) ); } errorHtml = edx.HtmlUtils.interpolateHtml(edx.HtmlUtils.HTML('<ul>{errors}</ul>'), {errors: errorHtml}); this.gentle_alert(errorHtml.toString()); abortSubmission = fileTooLarge || fileNotSelected || unallowedFileSubmitted || requiredFilesNotSubmitted; if (abortSubmission) { window.clearTimeout(timeoutId); this.enableSubmitButton(true); } else { settings = { type: 'POST', data: fd, processData: false, contentType: false, complete: this.enableSubmitButtonAfterResponse, success: function(response) { switch (response.success) { case 'submitted': case 'incorrect': case 'correct': that.render(response.contents); that.updateProgress(response); break; default: that.gentle_alert(response.success); } return Logger.log('problem_graded', [that.answers, response.contents], that.id); }, error: function(response) { that.gentle_alert(response.responseJSON.success); } }; $.ajaxWithPrefix('' + this.url + '/problem_check', settings); } }; Problem.prototype.submit = function() { if (!this.submit_save_waitfor(this.submit_internal)) { this.disableAllButtonsWhileRunning(this.submit_internal, true); } }; Problem.prototype.submit_internal = function() { var that = this; Logger.log('problem_check', this.answers); return $.postWithPrefix('' + this.url + '/problem_check', this.answers, function(response) { switch (response.success) { case 'submitted': case 'incorrect': case 'correct': window.SR.readTexts(that.get_sr_status(response.contents)); that.el.trigger('contentChanged', [that.id, response.contents, response]); that.render(response.contents, that.focus_on_submit_notification); that.updateProgress(response); // This is used by the Learning MFE to know when the Entrance Exam has been passed // for a user. The MFE is then able to respond appropriately. if (response.entrance_exam_passed) { window.parent.postMessage({type: 'entranceExam.passed'}, '*'); } break; default: that.saveNotification.hide(); that.gentle_alert(response.success); } return Logger.log('problem_graded', [that.answers, response.contents], that.id); }); }; /** * This method builds up an array of strings to send to the page screen-reader span. * It first gets all elements with class "status", and then looks to see if they are contained * in sections with aria-labels. If so, labels are prepended to the status element text. * If not, just the text of the status elements are returned. */ Problem.prototype.get_sr_status = function(contents) { var addedStatus, ariaLabel, element, labeledStatus, parentSection, statusElement, template, i, len; statusElement = $(contents).find('.status'); labeledStatus = []; for (i = 0, len = statusElement.length; i < len; i++) { element = statusElement[i]; parentSection = $(element).closest('.wrapper-problem-response'); addedStatus = false; if (parentSection) { ariaLabel = parentSection.attr('aria-label'); if (ariaLabel) { // Translators: This is only translated to allow for reordering of label and associated status.; template = gettext('{label}: {status}'); labeledStatus.push(edx.StringUtils.interpolate( template, { label: ariaLabel, status: $(element).text() } )); addedStatus = true; } } if (!addedStatus) { labeledStatus.push($(element).text()); } } return labeledStatus; }; Problem.prototype.reset = function() { return this.disableAllButtonsWhileRunning(this.reset_internal, false); }; Problem.prototype.reset_internal = function() { var that = this; Logger.log('problem_reset', this.answers); return $.postWithPrefix('' + this.url + '/problem_reset', { id: this.id }, function(response) { if (response.success) { that.el.trigger('contentChanged', [that.id, response.html, response]); that.render(response.html, that.scroll_to_problem_meta); that.updateProgress(response); return window.SR.readText(gettext('This problem has been reset.')); } else { return that.gentle_alert(response.msg); } }); }; // TODO this needs modification to deal with javascript responses; perhaps we // need something where responsetypes can define their own behavior when show // is called. Problem.prototype.show = function() { var that = this; Logger.log('problem_show', { problem: this.id }); return $.postWithPrefix('' + this.url + '/problem_show', function(response) { var answers; answers = response.answers; $.each(answers, function(key, value) { var safeKey = key.replace(':', '\\:'); // fix for courses which use url_names with colons, e.g. problem:question1 safeKey = safeKey.replace(/\./g, '\\.'); // fix for courses which use url_names with periods. e.g. question1.1 var answer; if (!$.isArray(value)) { answer = that.$('#answer_' + safeKey + ', #solution_' + safeKey); edx.HtmlUtils.setHtml(answer, edx.HtmlUtils.HTML(value)); Collapsible.setCollapsibles(answer); // Sometimes, `value` is just a string containing a MathJax formula. // If this is the case, jQuery will throw an error in some corner cases // because of an incorrect selector. We setup a try..catch so that // the script doesn't break in such cases. // // We will fallback to the second `if statement` below, if an // error is thrown by jQuery. try { return $(value).find('.detailed-solution'); } catch (e) { return {}; } // TODO remove the above once everything is extracted into its own // inputtype functions. } }); that.el.find('.capa_inputtype').each(function(index, inputtype) { var classes, cls, display, showMethod, i, len, results; classes = $(inputtype).attr('class').split(' '); results = []; for (i = 0, len = classes.length; i < len; i++) { cls = classes[i]; display = that.inputtypeDisplays[$(inputtype).attr('id')]; showMethod = that.inputtypeShowAnswerMethods[cls]; if (showMethod != null) { results.push(showMethod(inputtype, display, answers, response.correct_status_html)); } else { // eslint-disable-next-line no-void results.push(void 0); } } return results; }); if (typeof MathJax !== 'undefined' && MathJax !== null) { that.el.find('.problem > div').each(function(index, element) { return MathJax.Hub.Queue(['Typeset', MathJax.Hub, element]); }); } that.el.find('.show').attr('disabled', 'disabled'); that.updateProgress(response); that.clear_all_notifications(); that.showAnswerNotification.show(); that.focus_on_notification('show-answer'); }); }; Problem.prototype.clear_all_notifications = function() { this.submitNotification.remove(); this.gentleAlertNotification.hide(); this.saveNotification.hide(); this.showAnswerNotification.hide(); }; Problem.prototype.gentle_alert = function(msg) { edx.HtmlUtils.setHtml( this.el.find('.notification-gentle-alert .notification-message'), edx.HtmlUtils.HTML(msg) ); this.clear_all_notifications(); this.gentleAlertNotification.show(); this.gentleAlertNotification.focus(); }; Problem.prototype.save = function() { if (!this.submit_save_waitfor(this.save_internal)) { this.disableAllButtonsWhileRunning(this.save_internal, false); } }; Problem.prototype.save_internal = function() { var that = this; Logger.log('problem_save', this.answers); return $.postWithPrefix('' + this.url + '/problem_save', this.answers, function(response) { var saveMessage; saveMessage = response.msg; if (response.success) { that.el.trigger('contentChanged', [that.id, response.html, response]); edx.HtmlUtils.setHtml( that.el.find('.notification-save .notification-message'), edx.HtmlUtils.HTML(saveMessage) ); that.clear_all_notifications(); that.el.find('.wrapper-problem-response .message').hide(); that.saveNotification.show(); that.focus_on_save_notification(); } else { that.gentle_alert(saveMessage); } }); }; Problem.prototype.refreshMath = function(event, element) { var elid, eqn, jax, mathjaxPreprocessor, preprocessorTag, target; if (!element) { element = event.target; // eslint-disable-line no-param-reassign } elid = element.id.replace(/^input_/, ''); target = 'display_' + elid; // MathJax preprocessor is loaded by 'setupInputTypes' preprocessorTag = 'inputtype_' + elid; mathjaxPreprocessor = this.inputtypeDisplays[preprocessorTag]; if (typeof MathJax !== 'undefined' && MathJax !== null && MathJax.Hub.getAllJax(target)[0]) { jax = MathJax.Hub.getAllJax(target)[0]; eqn = $(element).val(); if (mathjaxPreprocessor) { eqn = mathjaxPreprocessor(eqn); } MathJax.Hub.Queue(['Text', jax, eqn], [this.updateMathML, jax, element]); } }; Problem.prototype.updateMathML = function(jax, element) { try { $('#' + element.id + '_dynamath').val(jax.root.toMathML('')); } catch (exception) { if (!exception.restart) { throw exception; } if (typeof MathJax !== 'undefined' && MathJax !== null) { MathJax.Callback.After([this.refreshMath, jax], exception.restart); } } }; Problem.prototype.refreshAnswers = function() { this.$('input.schematic').each(function(index, element) { return element.schematic.update_value(); }); this.$('.CodeMirror').each(function(index, element) { if (element.CodeMirror.save) { element.CodeMirror.save(); } }); this.answers = this.inputs.serialize(); }; /** * Used to check available answers and if something is checked (or the answer is set in some textbox), * the "Submit" button becomes enabled. Otherwise it is disabled by default. * * Arguments: * bind (boolean): used on the first check to attach event handlers to input fields * to change "Submit" enable status in case of some manipulations with answers */ Problem.prototype.submitAnswersAndSubmitButton = function(bind) { var answered, atLeastOneTextInputFound, oneTextInputFilled, that = this; if (bind === null || bind === undefined) { bind = false; // eslint-disable-line no-param-reassign } answered = true; atLeastOneTextInputFound = false; oneTextInputFilled = false; this.el.find('input:text').each(function(i, textField) { if ($(textField).is(':visible')) { atLeastOneTextInputFound = true; if ($(textField).val() !== '') { oneTextInputFilled = true; } if (bind) { $(textField).on('input', function() { that.saveNotification.hide(); that.showAnswerNotification.hide(); that.submitAnswersAndSubmitButton(); }); } } }); if (atLeastOneTextInputFound && !oneTextInputFilled) { answered = false; } this.el.find('.choicegroup').each(function(i, choicegroupBlock) { var checked; checked = false; $(choicegroupBlock).find('input[type=checkbox], input[type=radio]') .each(function(j, checkboxOrRadio) { if ($(checkboxOrRadio).is(':checked')) { checked = true; } if (bind) { $(checkboxOrRadio).on('click', function() { that.saveNotification.hide(); that.el.find('.show').removeAttr('disabled'); that.showAnswerNotification.hide(); that.submitAnswersAndSubmitButton(); }); } }); if (!checked) { answered = false; } }); this.el.find('select').each(function(i, selectField) { var selectedOption = $(selectField).find('option:selected').text() .trim(); if (selectedOption === 'Select an option') { answered = false; } if (bind) { $(selectField).on('change', function() { that.saveNotification.hide(); that.showAnswerNotification.hide(); that.submitAnswersAndSubmitButton(); }); } }); if (answered) { return this.enableSubmitButton(true); } else { return this.enableSubmitButton(false, false); } }; Problem.prototype.bindResetCorrectness = function() { // Loop through all input types. // Bind the reset functions at that scope. var $inputtypes, that = this; $inputtypes = this.el.find('.capa_inputtype').add(this.el.find('.inputtype')); return $inputtypes.each(function(index, inputtype) { var bindMethod, classes, cls, i, len, results; classes = $(inputtype).attr('class').split(' '); results = []; for (i = 0, len = classes.length; i < len; i++) { cls = classes[i]; bindMethod = that.bindResetCorrectnessByInputtype[cls]; if (bindMethod != null) { results.push(bindMethod(inputtype)); } else { // eslint-disable-next-line no-void results.push(void 0); } } return results; }); }; // Find all places where each input type displays its correct-ness // Replace them with their original state--'unanswered'. Problem.prototype.bindResetCorrectnessByInputtype = { // These are run at the scope of the capa inputtype // They should set handlers on each <input> to reset the whole. formulaequationinput: function(element) { return $(element).find('input').on('input', function() { var $p; $p = $(element).find('span.status'); $p.removeClass('correct incorrect submitted'); return $p.parent().removeAttr('class').addClass('unsubmitted'); }); }, choicegroup: function(element) { var $element, id; $element = $(element); id = ($element.attr('id').match(/^inputtype_(.*)$/))[1]; return $element.find('input').on('change', function() { var $status; $status = $('#status_' + id); if ($status[0]) { $status.removeAttr('class').addClass('status unanswered'); } else { $('<span>', { class: 'status unanswered', style: 'display: inline-block;', id: 'status_' + id }); } $element.find('label').find('span.status.correct').remove(); return $element.find('label').removeAttr('class'); }); }, 'option-input': function(element) { var $select, id; $select = $(element).find('select'); id = ($select.attr('id').match(/^input_(.*)$/))[1]; return $select.on('change', function() { return $('#status_' + id).removeAttr('class').addClass('unanswered') .find('.sr') .text(gettext('unsubmitted')); }); }, textline: function(element) { return $(element).find('input').on('input', function() { var $p; $p = $(element).find('span.status'); $p.removeClass('correct incorrect submitted'); return $p.parent().removeClass('correct incorrect').addClass('unsubmitted'); }); } }; Problem.prototype.inputtypeSetupMethods = { 'text-input-dynamath': function(element) { /* Return: function (eqn) -> eqn that preprocesses the user formula input before it is fed into MathJax. Return 'false' if no preprocessor specified */ var data, preprocessor, preprocessorClass, preprocessorClassName; data = $(element).find('.text-input-dynamath_data'); preprocessorClassName = data.data('preprocessor'); preprocessorClass = window[preprocessorClassName]; if (preprocessorClass == null) { return false; } else { preprocessor = new preprocessorClass(); return preprocessor.fn; } }, cminput: function(container) { var CodeMirrorEditor, CodeMirrorTextArea, element, id, linenumbers, mode, spaces, tabsize; element = $(container).find('textarea'); tabsize = element.data('tabsize'); mode = element.data('mode'); linenumbers = element.data('linenums'); spaces = Array(parseInt(tabsize, 10) + 1).join(' '); CodeMirrorEditor = CodeMirror.fromTextArea(element[0], { lineNumbers: linenumbers, indentUnit: tabsize, tabSize: tabsize, mode: mode, matchBrackets: true, lineWrapping: true, indentWithTabs: false, smartIndent: false, extraKeys: { Esc: function() { $('.grader-status').focus(); return false; }, Tab: function(cm) { cm.replaceSelection(spaces, 'end'); return false; } } }); id = element.attr('id').replace(/^input_/, ''); CodeMirrorTextArea = CodeMirrorEditor.getInputField(); CodeMirrorTextArea.setAttribute('id', 'cm-textarea-' + id); CodeMirrorTextArea.setAttribute('aria-describedby', 'cm-editor-exit-message-' + id + ' status_' + id); return CodeMirrorEditor; } }; Problem.prototype.inputtypeShowAnswerMethods = { choicegroup: function(element, display, answers, correctStatusHtml) { var answer, choice, inputId, i, len, results, $element, $inputLabel, $inputStatus; $element = $(element); inputId = $element.attr('id').replace(/inputtype_/, ''); inputId = inputId.replace(':', '\\:'); // fix for courses which use url_names with colons, e.g. problem:question1 var safeId = inputId.replace(/\./g, '\\.'); // fix for courses which use url_names with periods. e.g. question1.1 answer = answers[inputId]; results = []; for (i = 0, len = answer.length; i < len; i++) { choice = answer[i]; $inputLabel = $element.find('#input_' + safeId + '_' + choice + ' + label'); $inputStatus = $element.find('#status_' + safeId); // If the correct answer was already Submitted before "Show Answer" was selected, // the status HTML will already be present. Otherwise, inject the status HTML. // If the learner clicked a different answer after Submit, their submitted answers // will be marked as "unanswered". In that case, for correct answers update the // classes accordingly. if ($inputStatus.hasClass('unanswered')) { edx.HtmlUtils.append($inputLabel, edx.HtmlUtils.HTML(correctStatusHtml)); $inputLabel.addClass('choicegroup_correct'); } else if (!$inputLabel.hasClass('choicegroup_correct')) { // If the status HTML is not already present (due to clicking Submit), append // the status HTML for correct answers. edx.HtmlUtils.append($inputLabel, edx.HtmlUtils.HTML(correctStatusHtml)); $inputLabel.removeClass('choicegroup_incorrect'); results.push($inputLabel.addClass('choicegroup_correct')); } } return results; }, choicetextgroup: function(element, display, answers) { var answer, choice, inputId, i, len, results, $element; $element = $(element); inputId = $element.attr('id').replace(/inputtype_/, ''); answer = answers[inputId]; results = []; for (i = 0, len = answer.length; i < len; i++) { choice = answer[i]; results.push($element.find('section#forinput' + choice).addClass('choicetextgroup_show_correct')); } return results; }, imageinput: function(element, display, answers) { // answers is a dict of (answer_id, answer_text) for each answer for this question. // // @Examples: // {'anwser_id': { // 'rectangle': '(10,10)-(20,30);(12,12)-(40,60)', // 'regions': '[[10,10], [30,30], [10, 30], [30, 10]]' // } } var canvas, container, id, types, context, $element; types = { rectangle: function(ctx, coords) { var rects, reg; reg = /^\(([0-9]+),([0-9]+)\)-\(([0-9]+),([0-9]+)\)$/; rects = coords.replace(/\s*/g, '').split(/;/); $.each(rects, function(index, rect) { var abs, height, points, width; abs = Math.abs; points = reg.exec(rect); if (points) { width = abs(points[3] - points[1]); height = abs(points[4] - points[2]); ctx.rect(points[1], points[2], width, height); } }); ctx.stroke(); return ctx.fill(); }, regions: function(ctx, coords) { var parseCoords; parseCoords = function(coordinates) { var reg; reg = JSON.parse(coordinates); // Regions is list of lists [region1, region2, region3, ...] where regionN // is disordered list of points: [[1,1], [100,100], [50,50], [20, 70]]. // If there is only one region in the list, simpler notation can be used: // regions="[[10,10], [30,30], [10, 30], [30, 10]]" (without explicitly // setting outer list) if (typeof reg[0][0][0] === 'undefined') { // we have [[1,2],[3,4],[5,6]] - single region // instead of [[[1,2],[3,4],[5,6], [[1,2],[3,4],[5,6]]] // or [[[1,2],[3,4],[5,6]]] - multiple regions syntax reg = [reg]; } return reg; }; return $.each(parseCoords(coords), function(index, region) { ctx.beginPath(); $.each(region, function(idx, point) { if (idx === 0) { return ctx.moveTo(point[0], point[1]); } else { return ctx.lineTo(point[0], point[1]); } }); ctx.closePath(); ctx.stroke(); return ctx.fill(); }); } }; $element = $(element); id = $element.attr('id').replace(/inputtype_/, ''); container = $element.find('#answer_' + id); canvas = document.createElement('canvas'); canvas.width = container.data('width'); canvas.height = container.data('height'); if (canvas.getContext) { context = canvas.getContext('2d'); } else { console.log('Canvas is not supported.'); // eslint-disable-line no-console } context.fillStyle = 'rgba(255,255,255,.3)'; context.strokeStyle = '#FF0000'; context.lineWidth = '2'; if (answers[id]) { $.each(answers[id], function(key, value) { if ((types[key] !== null && types[key] !== undefined) && value) { types[key](context, value); } }); edx.HtmlUtils.setHtml(container, edx.HtmlUtils.HTML(canvas)); } else { console.log('Answer is absent for image input with id=' + id); // eslint-disable-line no-console } } }; /** * Used to keep the buttons disabled while operationCallback is running. * * params: * 'operationCallback' is an operation to be run. * isFromCheckOperation' is a boolean to keep track if 'operationCallback' was * from submit, if so then text of submit button will be changed as well. * */ Problem.prototype.disableAllButtonsWhileRunning = function(operationCallback, isFromCheckOperation) { var that = this; var allButtons = [this.resetButton, this.saveButton, this.showButton, this.hintButton, this.submitButton]; var initiallyEnabledButtons = allButtons.filter(function(button) { return !button.attr('disabled'); }); this.enableButtons(initiallyEnabledButtons, false, isFromCheckOperation); return operationCallback().always(function() { return that.enableButtons(initiallyEnabledButtons, true, isFromCheckOperation); }); }; /** * Enables/disables buttons by removing/adding the disabled attribute. The submit button is checked * separately due to the changing text it contains. * * params: * 'buttons' is an array of buttons that will have their 'disabled' attribute modified * 'enable' a boolean to either enable or disable the buttons passed in the first parameter * 'changeSubmitButtonText' is a boolean to keep track if operation was initiated * from submit so that text of submit button will also be changed while disabling/enabling * the submit button. */ Problem.prototype.enableButtons = function(buttons, enable, changeSubmitButtonText) { var that = this; buttons.forEach(function(button) { if (button.hasClass('submit')) { that.enableSubmitButton(enable, changeSubmitButtonText); } else if (enable) { button.removeAttr('disabled'); } else { button.attr({disabled: 'disabled'}); } }); }; /** * Used to disable submit button to reduce chance of accidental double-submissions. * * params: * 'enable' is a boolean to determine enabling/disabling of submit button. * 'changeText' is a boolean to determine if there is need to change the * text of submit button as well. */ Problem.prototype.enableSubmitButton = function(enable, changeText) { var submitCanBeEnabled; if (changeText === null || changeText === undefined) { changeText = true; // eslint-disable-line no-param-reassign } if (enable) { submitCanBeEnabled = this.submitButton.data('should-enable-submit-button') === 'True'; if (submitCanBeEnabled) { this.submitButton.removeAttr('disabled'); } if (changeText) { this.submitButtonLabel.text(this.submitButtonSubmitText); } } else { this.submitButton.attr({disabled: 'disabled'}); if (changeText) { this.submitButtonLabel.text(this.submitButtonSubmittingText); } } }; Problem.prototype.enableSubmitButtonAfterResponse = function() { this.has_response = true; if (!this.has_timed_out) { // Server has returned response before our timeout. return this.enableSubmitButton(false); } else { return this.enableSubmitButton(true); } }; Problem.prototype.enableSubmitButtonAfterTimeout = function() { var enableSubmitButton, that = this; this.has_timed_out = false; this.has_response = false; enableSubmitButton = function() { that.has_timed_out = true; if (that.has_response) { that.enableSubmitButton(true); } }; return window.setTimeout(enableSubmitButton, 750); }; Problem.prototype.hint_button = function() { // Store the index of the currently shown hint as an attribute. // Use that to compute the next hint number when the button is clicked. var hintContainer, hintIndex, nextIndex, that = this; hintContainer = this.$('.problem-hint'); hintIndex = hintContainer.attr('hint_index'); // eslint-disable-next-line no-void if (hintIndex === void 0) { nextIndex = 0; } else { nextIndex = parseInt(hintIndex, 10) + 1; } return $.postWithPrefix('' + this.url + '/hint_button', { hint_index: nextIndex, input_id: this.id }, function(response) { var hintMsgContainer; if (response.success) { hintMsgContainer = that.$('.problem-hint .notification-message'); hintContainer.attr('hint_index', response.hint_index); edx.HtmlUtils.setHtml(hintMsgContainer, edx.HtmlUtils.HTML(response.msg)); MathJax.Hub.Queue(['Typeset', MathJax.Hub, hintContainer[0]]); if (response.should_enable_next_hint) { that.hintButton.removeAttr('disabled'); } else { that.hintButton.attr({disabled: 'disabled'}); } that.el.find('.notification-hint').show(); that.focus_on_hint_notification(nextIndex); } else { that.gentle_alert(response.msg); } }); }; return Problem; }).call(this); }).call(this);