Files
edx-platform/common/lib/xmodule/xmodule/js/src/capa/display.coffee
2014-03-20 16:55:08 +02:00

639 lines
23 KiB
CoffeeScript

class @Problem
constructor: (element) ->
@el = $(element).find('.problems-wrapper')
@id = @el.data('problem-id')
@element_id = @el.attr('id')
@url = @el.data('url')
@render()
$: (selector) ->
$(selector, @el)
bind: =>
if MathJax?
@el.find('.problem > div').each (index, element) =>
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element]
window.update_schematics()
problem_prefix = @element_id.replace(/problem_/,'')
@inputs = @$("[id^=input_#{problem_prefix}_]")
@$('div.action input:button').click @refreshAnswers
@$('div.action input.check').click @check_fd
@$('div.action input.reset').click @reset
@$('div.action button.show').click @show
@$('div.action input.save').click @save
@bindResetCorrectness()
# Collapsibles
Collapsible.setCollapsibles(@el)
# Dynamath
@$('input.math').keyup(@refreshMath)
if MathJax?
@$('input.math').each (index, element) =>
MathJax.Hub.Queue [@refreshMath, null, element]
renderProgressState: =>
detail = @el.data('progress_detail')
status = @el.data('progress_status')
# Render 'x/y point(s)' if student has attempted question
if status != 'none' and detail? and detail.indexOf('/') > 0
a = detail.split('/')
earned = parseFloat(a[0])
possible = parseFloat(a[1])
# This comment needs to be on one line to be properly scraped for the translators. Sry for length.
`// Translators: %(earned)s is the number of points earned. %(total)s 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)`
progress_template = ngettext('(%(earned)s/%(possible)s point)', '(%(earned)s/%(possible)s points)', possible)
progress = interpolate(progress_template, {'earned': earned, 'possible': possible}, true)
# Render 'x point(s) possible' if student has not yet attempted question
if status == 'none' and detail? and detail.indexOf('/') > 0
a = detail.split('/')
possible = parseFloat(a[1])
`// Translators: %(num_points)s is the number of points possible (examples: 1, 3, 10). There will always be at least 1 point possible.`
progress_template = ngettext("(%(num_points)s point possible)", "(%(num_points)s points possible)", possible)
progress = interpolate(progress_template, {'num_points': possible}, true)
@$('.problem-progress').html(progress)
updateProgress: (response) =>
if response.progress_changed
@el.data('progress_status', response.progress_status)
@el.data('progress_detail', response.progress_detail)
@el.trigger('progressChanged')
@renderProgressState()
forceUpdate: (response) =>
@el.data('progress_status', response.progress_status)
@el.data('progress_detail', response.progress_detail)
@el.trigger('progressChanged')
@renderProgressState()
queueing: =>
@queued_items = @$(".xqueue")
@num_queued_items = @queued_items.length
if @num_queued_items > 0
if window.queuePollerID # Only one poller 'thread' per Problem
window.clearTimeout(window.queuePollerID)
queuelen = @get_queuelen()
window.queuePollerID = window.setTimeout(@poll, queuelen*10)
# Retrieves the minimum queue length of all queued items
get_queuelen: =>
minlen = Infinity
@queued_items.each (index, qitem) ->
len = parseInt($.text(qitem))
if len < minlen
minlen = len
return minlen
poll: =>
$.postWithPrefix "#{@url}/problem_get", (response) =>
# If queueing status changed, then render
@new_queued_items = $(response.html).find(".xqueue")
if @new_queued_items.length isnt @num_queued_items
@el.html(response.html)
JavascriptLoader.executeModuleScripts @el, () =>
@setupInputTypes()
@bind()
@num_queued_items = @new_queued_items.length
if @num_queued_items == 0
@forceUpdate response
delete window.queuePollerID
else
# TODO: Some logic to dynamically adjust polling rate based on queuelen
window.queuePollerID = window.setTimeout(@poll, 1000)
# 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
# input_id: the input_id 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
# callback: the function that will be called once the AJAX call has been completed.
# It will be passed a response object
@inputAjax: (url, input_id, dispatch, data, callback) ->
data['dispatch'] = dispatch
data['input_id'] = input_id
$.postWithPrefix "#{url}/input_ajax", data, callback
render: (content) ->
if content
@el.attr({'aria-busy': 'true', 'aria-live': 'off', 'aria-atomic': 'false'})
@el.html(content)
JavascriptLoader.executeModuleScripts @el, () =>
@setupInputTypes()
@bind()
@queueing()
@el.attr('aria-busy', 'false')
else
$.postWithPrefix "#{@url}/problem_get", (response) =>
@el.html(response.html)
JavascriptLoader.executeModuleScripts @el, () =>
@setupInputTypes()
@bind()
@queueing()
@forceUpdate response
# TODO add hooks for problem types here by inspecting response.html and doing
# stuff if a div w a class is found
setupInputTypes: =>
@inputtypeDisplays = {}
@el.find(".capa_inputtype").each (index, inputtype) =>
classes = $(inputtype).attr('class').split(' ')
id = $(inputtype).attr('id')
for cls in classes
setupMethod = @inputtypeSetupMethods[cls]
if setupMethod?
@inputtypeDisplays[id] = setupMethod(inputtype)
# If some function wants to be called before sending the answer to the
# server, give it a chance to do so.
#
# check_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 "Waitfor
# Exception". 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 check button before sending
# off @answers
check_save_waitfor: (callback) =>
for inp in @inputs
if ($(inp).is("input[waitfor]"))
try
$(inp).data("waitfor")(() =>
@refreshAnswers()
callback()
)
catch e
if e.name == "Waitfor Exception"
alert e.message
else
alert "Could not grade your answer. The submission was aborted."
throw e
return true
else
return false
###
# 'check_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
###
check_fd: =>
# If there are no file inputs in the problem, we can fall back on @check
if @el.find('input:file').length == 0
@check()
return
if not window.FormData
alert "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."
return
fd = new FormData()
# Sanity checks on submission
max_filesize = 4*1000*1000 # 4 MB
file_too_large = false
file_not_selected = false
required_files_not_submitted = false
unallowed_file_submitted = false
errors = []
@inputs.each (index, element) ->
if element.type is 'file'
required_files = $(element).data("required_files")
allowed_files = $(element).data("allowed_files")
for file in element.files
if allowed_files.length != 0 and file.name not in allowed_files
unallowed_file_submitted = true
errors.push "You submitted #{file.name}; only #{allowed_files} are allowed."
if file.name in required_files
required_files.splice(required_files.indexOf(file.name), 1)
if file.size > max_filesize
file_too_large = true
max_size = max_filesize / (1000*1000)
errors.push "Your file #{file.name} is too large (max size: {max_size}MB)"
fd.append(element.id, file)
if element.files.length == 0
file_not_selected = true
fd.append(element.id, '') # In case we want to allow submissions with no file
if required_files.length != 0
required_files_not_submitted = true
errors.push "You did not submit the required files: #{required_files}."
else
fd.append(element.id, element.value)
if file_not_selected
errors.push 'You did not select any files to submit'
error_html = '<ul>\n'
for error in errors
error_html += '<li>' + error + '</li>\n'
error_html += '</ul>'
@gentle_alert error_html
abort_submission = file_too_large or file_not_selected or unallowed_file_submitted or required_files_not_submitted
settings =
type: "POST"
data: fd
processData: false
contentType: false
success: (response) =>
switch response.success
when 'incorrect', 'correct'
@render(response.contents)
@updateProgress response
else
@gentle_alert response.success
Logger.log 'problem_graded', [@answers, response.contents], @id
if not abort_submission
$.ajaxWithPrefix("#{@url}/problem_check", settings)
check: =>
if not @check_save_waitfor(@check_internal)
@check_internal()
check_internal: =>
Logger.log 'problem_check', @answers
# Segment.io
analytics.track "Problem Checked",
problem_id: @id
answers: @answers
$.postWithPrefix "#{@url}/problem_check", @answers, (response) =>
switch response.success
when 'incorrect', 'correct'
window.SR.readElts($(response.contents).find('.status'))
@render(response.contents)
@updateProgress response
if @el.hasClass 'showed'
@el.removeClass 'showed'
@$('div.action input.check').focus()
else
@gentle_alert response.success
Logger.log 'problem_graded', [@answers, response.contents], @id
reset: =>
Logger.log 'problem_reset', @answers
$.postWithPrefix "#{@url}/problem_reset", id: @id, (response) =>
@render(response.html)
@updateProgress response
# TODO this needs modification to deal with javascript responses; perhaps we
# need something where responsetypes can define their own behavior when show
# is called.
show: =>
if !@el.hasClass 'showed'
Logger.log 'problem_show', problem: @id
answer_text = []
$.postWithPrefix "#{@url}/problem_show", (response) =>
answers = response.answers
$.each answers, (key, value) =>
if $.isArray(value)
for choice in value
@$("label[for='input_#{key}_#{choice}']").attr correct_answer: 'true'
answer_text.push('<p>' + gettext('Answer:') + ' ' + value + '</p>')
else
answer = @$("#answer_#{key}, #solution_#{key}")
answer.html(value)
Collapsible.setCollapsibles(answer)
solution = $(value).find('.detailed-solution')
if solution.length
answer_text.push(solution)
else
answer_text.push('<p>' + gettext('Answer:') + ' ' + value + '</p>')
# TODO remove the above once everything is extracted into its own
# inputtype functions.
@el.find(".capa_inputtype").each (index, inputtype) =>
classes = $(inputtype).attr('class').split(' ')
for cls in classes
display = @inputtypeDisplays[$(inputtype).attr('id')]
showMethod = @inputtypeShowAnswerMethods[cls]
showMethod(inputtype, display, answers) if showMethod?
if MathJax?
@el.find('.problem > div').each (index, element) =>
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element]
`// Translators: the word Answer here refers to the answer to a problem the student must solve.`
@$('.show-label').text gettext('Hide Answer')
@$('.show-label .sr').text gettext('Hide Answer')
@el.addClass 'showed'
@updateProgress response
window.SR.readElts(answer_text)
else
@$('[id^=answer_], [id^=solution_]').text ''
@$('[correct_answer]').attr correct_answer: null
@el.removeClass 'showed'
`// Translators: the word Answer here refers to the answer to a problem the student must solve.`
@$('.show-label').text gettext('Show Answer')
@$('.show-label .sr').text gettext('Reveal Answer')
window.SR.readText(gettext('Answer hidden'))
@el.find(".capa_inputtype").each (index, inputtype) =>
display = @inputtypeDisplays[$(inputtype).attr('id')]
classes = $(inputtype).attr('class').split(' ')
for cls in classes
hideMethod = @inputtypeHideAnswerMethods[cls]
hideMethod(inputtype, display) if hideMethod?
gentle_alert: (msg) =>
if @el.find('.capa_alert').length
@el.find('.capa_alert').remove()
alert_elem = "<div class='capa_alert'>" + msg + "</div>"
@el.find('.action').after(alert_elem)
@el.find('.capa_alert').css(opacity: 0).animate(opacity: 1, 700)
window.SR.readElts(msg)
save: =>
if not @check_save_waitfor(@save_internal)
@save_internal()
save_internal: =>
Logger.log 'problem_save', @answers
$.postWithPrefix "#{@url}/problem_save", @answers, (response) =>
saveMessage = response.msg
@gentle_alert saveMessage
@updateProgress response
refreshMath: (event, element) =>
element = event.target unless element
elid = element.id.replace(/^input_/,'')
target = "display_" + elid
# MathJax preprocessor is loaded by 'setupInputTypes'
preprocessor_tag = "inputtype_" + elid
mathjax_preprocessor = @inputtypeDisplays[preprocessor_tag]
if MathJax? and jax = MathJax.Hub.getAllJax(target)[0]
eqn = $(element).val()
if mathjax_preprocessor
eqn = mathjax_preprocessor(eqn)
MathJax.Hub.Queue(['Text', jax, eqn], [@updateMathML, jax, element])
return # Explicit return for CoffeeScript
updateMathML: (jax, element) =>
try
$("##{element.id}_dynamath").val(jax.root.toMathML '')
catch exception
throw exception unless exception.restart
if MathJax?
MathJax.Callback.After [@refreshMath, jax], exception.restart
refreshAnswers: =>
@$('input.schematic').each (index, element) ->
element.schematic.update_value()
@$(".CodeMirror").each (index, element) ->
element.CodeMirror.save() if element.CodeMirror.save
@answers = @inputs.serialize()
bindResetCorrectness: ->
# Loop through all input types
# Bind the reset functions at that scope.
$inputtypes = @el.find(".capa_inputtype").add(@el.find(".inputtype"))
$inputtypes.each (index, inputtype) =>
classes = $(inputtype).attr('class').split(' ')
for cls in classes
bindMethod = @bindResetCorrectnessByInputtype[cls]
if bindMethod?
bindMethod(inputtype)
# Find all places where each input type displays its correct-ness
# Replace them with their original state--'unanswered'.
bindResetCorrectnessByInputtype:
# These are run at the scope of the capa inputtype
# They should set handlers on each <input> to reset the whole.
formulaequationinput: (element) ->
$(element).find('input').on 'input', ->
$p = $(element).find('p.status')
`// Translators: the word unanswered here is about answering a problem the student must solve.`
$p.text gettext("unanswered")
$p.parent().removeClass().addClass "unanswered"
choicegroup: (element) ->
$element = $(element)
id = ($element.attr('id').match /^inputtype_(.*)$/)[1]
$element.find('input').on 'change', ->
$status = $("#status_#{id}")
if $status[0] # We found a status icon.
$status.removeClass().addClass "unanswered"
$status.empty().css 'display', 'inline-block'
else
# Recreate the unanswered dot on left.
$("<span>", {"class": "unanswered", "style": "display: inline-block;", "id": "status_#{id}"})
$element.find("label").removeClass()
'option-input': (element) ->
$select = $(element).find('select')
id = ($select.attr('id').match /^input_(.*)$/)[1]
$select.on 'change', ->
$status = $("#status_#{id}")
.removeClass().addClass("unanswered")
.find('span').text(gettext('Status: unsubmitted'))
textline: (element) ->
$(element).find('input').on 'input', ->
$p = $(element).find('p.status')
`// Translators: the word unanswered here is about answering a problem the student must solve.`
$p.text gettext("unanswered")
$p.parent().removeClass("correct incorrect").addClass "unanswered"
inputtypeSetupMethods:
'text-input-dynamath': (element) =>
###
Return: function (eqn) -> eqn that preprocesses the user formula input before
it is fed into MathJax. Return 'false' if no preprocessor specified
###
data = $(element).find('.text-input-dynamath_data')
preprocessorClassName = data.data('preprocessor')
preprocessorClass = window[preprocessorClassName]
if not preprocessorClass?
return false
else
preprocessor = new preprocessorClass()
return preprocessor.fn
javascriptinput: (element) =>
data = $(element).find(".javascriptinput_data")
params = data.data("params")
submission = data.data("submission")
evaluation = data.data("evaluation")
problemState = data.data("problem_state")
displayClass = window[data.data('display_class')]
if evaluation == ''
evaluation = null
container = $(element).find(".javascriptinput_container")
submissionField = $(element).find(".javascriptinput_input")
display = new displayClass(problemState, submission, evaluation, container, submissionField, params)
display.render()
return display
cminput: (container) =>
element = $(container).find("textarea")
tabsize = element.data("tabsize")
mode = element.data("mode")
linenumbers = element.data("linenums")
spaces = Array(parseInt(tabsize) + 1).join(" ")
CodeMirror.fromTextArea element[0], {
lineNumbers: linenumbers
indentUnit: tabsize
tabSize: tabsize
mode: mode
matchBrackets: true
lineWrapping: true
indentWithTabs: false
smartIndent: false
extraKeys: {
"Esc": (cm) ->
$(".grader-status").focus()
return false
"Tab": (cm) ->
cm.replaceSelection(spaces, "end")
return false
}
}
inputtypeShowAnswerMethods:
choicegroup: (element, display, answers) =>
element = $(element)
input_id = element.attr('id').replace(/inputtype_/,'')
answer = answers[input_id]
for choice in answer
element.find("label[for='input_#{input_id}_#{choice}']").addClass 'choicegroup_correct'
javascriptinput: (element, display, answers) =>
answer_id = $(element).attr('id').split("_")[1...].join("_")
answer = JSON.parse(answers[answer_id])
display.showAnswer(answer)
choicetextgroup: (element, display, answers) =>
element = $(element)
input_id = element.attr('id').replace(/inputtype_/,'')
answer = answers[input_id]
for choice in answer
element.find("section#forinput#{choice}").addClass 'choicetextgroup_show_correct'
imageinput: (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]]'
# } }
types =
rectangle: (ctx, coords) =>
reg = /^\(([0-9]+),([0-9]+)\)-\(([0-9]+),([0-9]+)\)$/
rects = coords.replace(/\s*/g, '').split(/;/)
$.each rects, (index, rect) =>
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()
ctx.fill()
regions: (ctx, coords) =>
parseCoords = (coords) =>
reg = JSON.parse(coords)
# 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
$.each parseCoords(coords), (index, region) =>
ctx.beginPath()
$.each region, (index, point) =>
if index is 0
ctx.moveTo(point[0], point[1])
else
ctx.lineTo(point[0], point[1]);
ctx.closePath()
ctx.stroke()
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
ctx = canvas.getContext('2d')
else
return console.log 'Canvas is not supported.'
ctx.fillStyle = 'rgba(255,255,255,.3)';
ctx.strokeStyle = "#FF0000";
ctx.lineWidth = "2";
if answers[id]
$.each answers[id], (key, value) =>
types[key](ctx, value) if types[key]? and value
container.html(canvas)
else
console.log "Answer is absent for image input with id=#{id}"
inputtypeHideAnswerMethods:
choicegroup: (element, display) =>
element = $(element)
element.find('label').removeClass('choicegroup_correct')
javascriptinput: (element, display) =>
display.hideAnswer()
choicetextgroup: (element, display) =>
element = $(element)
element.find("section[id^='forinput']").removeClass('choicetextgroup_show_correct')