Merge pull request #217 from edx/feature/jkarni/jsinput

Feature/jkarni/jsinput
This commit is contained in:
jkarni
2013-07-08 12:51:04 -07:00
11 changed files with 700 additions and 2 deletions

View File

@@ -451,6 +451,68 @@ class JavascriptInput(InputTypeBase):
registry.register(JavascriptInput)
#-----------------------------------------------------------------------------
class JSInput(InputTypeBase):
"""
DO NOT USE! HAS NOT BEEN TESTED BEYOND 700X PROBLEMS, AND MAY CHANGE IN
BACKWARDS-INCOMPATIBLE WAYS.
Inputtype for general javascript inputs. Intended to be used with
customresponse.
Loads in a sandboxed iframe to help prevent css and js conflicts between
frame and top-level window.
iframe sandbox whitelist:
- allow-scripts
- allow-popups
- allow-forms
- allow-pointer-lock
This in turn means that the iframe cannot directly access the top-level
window elements.
Example:
<jsinput html_file="/static/test.html"
gradefn="grade"
height="500"
width="400"/>
See the documentation in the /doc/public folder for more information.
"""
template = "jsinput.html"
tags = ['jsinput']
@classmethod
def get_attributes(cls):
"""
Register the attributes.
"""
return [Attribute('params', None), # extra iframe params
Attribute('html_file', None),
Attribute('gradefn', "gradefn"),
Attribute('get_statefn', None), # Function to call in iframe
# to get current state.
Attribute('set_statefn', None), # Function to call iframe to
# set state
Attribute('width', "400"), # iframe width
Attribute('height', "300")] # iframe height
def _extra_context(self):
context = {
'applet_loader': '/static/js/capa/src/jsinput.js',
'saved_state': self.value
}
return context
registry.register(JSInput)
#-----------------------------------------------------------------------------
class TextLine(InputTypeBase):

View File

@@ -935,7 +935,7 @@ class CustomResponse(LoncapaResponse):
'chemicalequationinput', 'vsepr_input',
'drag_and_drop_input', 'editamoleculeinput',
'designprotein2dinput', 'editageneinput',
'annotationinput']
'annotationinput', 'jsinput']
def setup_response(self):
xml = self.xml

View File

@@ -0,0 +1,64 @@
<section id="inputtype_${id}" class="jsinput"
data="${gradefn}"
% if saved_state:
data-stored="${saved_state|x}"
% endif
% if get_statefn:
data-getstate="${get_statefn}"
% endif
% if set_statefn:
data-setstate="${set_statefn}"
% endif
>
<div class="script_placeholder" data-src="${applet_loader}"/>
% if status == 'unsubmitted':
<div class="unanswered" id="status_${id}">
% elif status == 'correct':
<div class="correct" id="status_${id}">
% elif status == 'incorrect':
<div class="incorrect" id="status_${id}">
% elif status == 'incomplete':
<div class="incorrect" id="status_${id}">
% endif
<iframe name="iframe_${id}"
id="iframe_${id}"
sandbox="allow-scripts allow-popups allow-same-origin allow-forms allow-pointer-lock"
seamless="seamless"
frameborder="0"
src="${html_file}"
height="${height}"
width="${width}"
/>
<input type="hidden" name="input_${id}" id="input_${id}"
waitfor=""
value="${value|h}"/>
<br/>
<p id="answer_${id}" class="answer"></p>
<p class="status">
% if status == 'unsubmitted':
unanswered
% elif status == 'correct':
correct
% elif status == 'incorrect':
incorrect
% elif status == 'incomplete':
incomplete
% endif
</p>
<br/> <br/>
<div class="error_message" style="padding: 5px 5px 5px 5px; background-color:#FA6666; height:60px;width:400px; display: none"></div>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
</div>
% endif
% if msg:
<span class="message">${msg|n}</span>
% endif
</section>

View File

@@ -16,8 +16,16 @@ h2 {
}
}
iframe[seamless]{
background-color: transparent;
border: 0px none transparent;
padding: 0px;
overflow: hidden;
}
.inline-error {
color: darken($error-red, 10%);
color: darken($error-red, 11%);
}

View File

@@ -129,6 +129,30 @@ class @Problem
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_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_waitfor: =>
for inp in @inputs
if ($(inp).is("input[waitfor]"))
try
$(inp).data("waitfor")()
@refreshAnswers()
catch e
if e.name == "Waitfor Exception"
alert e.message
else
alert "Could not grade your answer. The submission was aborted."
throw e
###
# 'check_fd' uses FormData to allow file submissions in the 'problem_check' dispatch,
@@ -213,6 +237,7 @@ class @Problem
$.ajaxWithPrefix("#{@url}/problem_check", settings)
check: =>
@check_waitfor()
Logger.log 'problem_check', @answers
$.postWithPrefix "#{@url}/problem_check", @answers, (response) =>
switch response.success

View File

View File

@@ -0,0 +1,70 @@
describe("A jsinput has:", function () {
beforeEach(function () {
$('#fixture').remove();
$.ajax({
async: false,
url: 'mainfixture.html',
success: function(data) {
$('body').append($(data));
}
});
});
describe("The jsinput constructor", function(){
var iframe1 = $(document).find('iframe')[0];
var testJsElem = jsinputConstructor({
id: 1,
elem: iframe1,
passive: false
});
it("Returns an object", function(){
expect(typeof(testJsElem)).toEqual('object');
});
it("Adds the object to the jsinput array", function() {
expect(jsinput.exists(1)).toBe(true);
});
describe("The returned object", function() {
it("Has a public 'update' method", function(){
expect(testJsElem.update).toBeDefined();
});
it("Returns an 'update' that is idempotent", function(){
var orig = testJsElem.update();
for (var i = 0; i++; i < 5) {
expect(testJsElem.update()).toEqual(orig);
}
});
it("Changes the parent's inputfield", function() {
testJsElem.update();
});
});
});
describe("The walkDOM functions", function() {
walkDOM();
it("Creates (at least) one object per iframe", function() {
jsinput.arr.length >= 2;
});
it("Does not create multiple objects with the same id", function() {
while (jsinput.arr.length > 0) {
var elem = jsinput.arr.pop();
expect(jsinput.exists(elem.id)).toBe(false);
}
});
});
})

View File

@@ -0,0 +1,118 @@
<html>
<head>
<title> JSinput jasmine test </title>
</head>
<body>
<section id="inputtype_1"
data="gradefn"
data-setstate="setinput"
class="jsinput">
<div class="script_placeholder" />
<iframe name="iframe_1"
sandbox="allow-scripts
allow-popups
allow-same-origin
allow-forms
allow-pointer-lock"
seamless="seamless"
height="500"
width="500">
<html>
<head>
<title>
JS input test 1
</title>
<script type="text/javascript">
function gradefn () {
var ans = document.getElementById("one").value;
console.log("I've been called!");
return ans
}
function setinput(val) {
document.getElementById("one").value(val);
return;
}
</script>
</head>
<body>
<p>Simple js input test. Defines a js function that returns the value in
the input field below when called. </p>
<form>
<input id='one' type="TEXT"/>
</form>
</body>
</html>
</iframe>
<input type="hidden" name="input_1" id="input_1" value="${value|h}"/>
<br/>
<button id="update_1" class="update">Update</button>
<p id="answer_1" class="answer"></p>
<p class="status">
</p>
<br/> <br/>
<div class="error_message" ></div>
</section>
<section id="inputtype_2" data="gradefn" class="jsinput">
<div class="script_placeholder" />
<iframe name="iframe_2"
sandbox="allow-scripts
allow-popups
allow-same-origin
allow-forms
allow-pointer-lock"
seamless="seamless"
height="500"
width="500" >
<html>
<head>
<title>
JS input test
</title>
<script type="text/javascript">
function gradefn () {
var ans = document.getElementById("one").value;
console.log("I've been called!");
return ans
}
</script>
</head>
<body>
<p>Simple js input test. Defines a js function that returns the value in
the input field below when called. </p>
<form>
<input id='two' type="TEXT"/>
</form>
</body>
</html>
</iframe>
<input type="hidden" name="input_2" id="input_2" value="${value|h}"/>
<br/>
<button id="update_2" class="update">Update</button>
<p id="answer_2" class="answer"></p>
<p class="status">
</p>
<br/> <br/>
<div class="error_message"></div>
</section>
</body>
</html>

View File

@@ -0,0 +1,199 @@
(function (jsinput, undefined) {
// Initialize js inputs on current page.
// N.B.: No library assumptions about the iframe can be made (including,
// most relevantly, jquery). Keep in mind what happens in which context
// when modifying this file.
/* Check whether there is anything to be done */
// When all the problems are first loaded, we want to make sure the
// constructor only runs once for each iframe; but we also want to make
// sure that if part of the page is reloaded (e.g., a problem is
// submitted), the constructor is called again.
if (!jsinput) {
jsinput = {
runs : 1,
arr : [],
exists : function(id) {
jsinput.arr.filter(function(e, i, a) {
return e.id = id;
});
}
};
}
jsinput.runs++;
/* Utils */
// Take a string and find the nested object that corresponds to it. E.g.:
// deepKey(obj, "an.example") -> obj["an"]["example"]
var _deepKey = function(obj, path){
for (var i = 0, p=path.split('.'), len = p.length; i < len; i++){
obj = obj[p[i]];
}
return obj;
};
/* END Utils */
function jsinputConstructor(spec) {
// Define an class that will be instantiated for each jsinput element
// of the DOM
// 'that' is the object returned by the constructor. It has a single
// public method, "update", which updates the hidden input field.
var that = {};
/* Private methods */
var sect = $(spec.elem).parent().find('section[class="jsinput"]');
var sectAttr = function (e) { return $(sect).attr(e); };
var thisIFrame = $(spec.elem).
find('iframe[name^="iframe_"]').
get(0);
var cWindow = thisIFrame.contentWindow;
// Get the hidden input field to pass to customresponse
function _inputField() {
var parent = $(spec.elem).parent();
return parent.find('input[id^="input_"]');
}
var inputField = _inputField();
// Get the grade function name
var getGradeFn = sectAttr("data");
// Get state getter
var getStateGetter = sectAttr("data-getstate");
// Get state setter
var getStateSetter = sectAttr("data-setstate");
// Get stored state
var getStoredState = sectAttr("data-stored");
// Put the return value of gradeFn in the hidden inputField.
var update = function () {
var ans;
ans = _deepKey(cWindow, gradeFn)();
// setstate presumes getstate, so don't getstate unless setstate is
// defined.
if (getStateGetter && getStateSetter) {
var state, store;
state = unescape(_deepKey(cWindow, getStateGetter)());
store = {
answer: ans,
state: state
};
inputField.val(JSON.stringify(store));
} else {
inputField.val(ans);
}
return;
};
/* Public methods */
that.update = update;
/* Initialization */
jsinput.arr.push(that);
// Put the update function as the value of the inputField's "waitfor"
// attribute so that it is called when the check button is clicked.
function bindCheck() {
inputField.data('waitfor', that.update);
return;
}
var gradeFn = getGradeFn;
bindCheck();
// Check whether application takes in state and there is a saved
// state to give it. If getStateSetter is specified but calling it
// fails, wait and try again, since the iframe might still be
// loading.
if (getStateSetter && getStoredState) {
var sval, jsonVal;
try {
jsonVal = JSON.parse(getStoredState);
} catch (err) {
jsonVal = getStoredState;
}
if (typeof(jsonVal) === "object") {
sval = jsonVal["state"];
} else {
sval = jsonVal;
}
// Try calling setstate every 200ms while it throws an exception,
// up to five times; give up after that.
// (Functions in the iframe may not be ready when we first try
// calling it, but might just need more time. Give the functions
// more time.)
function whileloop(n) {
if (n < 5){
try {
_deepKey(cWindow, getStateSetter)(sval);
} catch (err) {
setTimeout(whileloop(n+1), 200);
}
}
else {
console.debug("Error: could not set state");
}
}
whileloop(0);
}
return that;
}
function walkDOM() {
var newid;
// Find all jsinput elements, and create a jsinput object for each one
var all = $(document).find('section[class="jsinput"]');
all.each(function(index, value) {
// Get just the mako variable 'id' from the id attribute
newid = $(value).attr("id").replace(/^inputtype_/, "");
if (!jsinput.exists(newid)){
var newJsElem = jsinputConstructor({
id: newid,
elem: value,
});
}
});
}
// This is ugly, but without a timeout pages with multiple/heavy jsinputs
// don't load properly.
if ($.isReady) {
setTimeout(walkDOM, 300);
} else {
$(document).ready(setTimeout(walkDOM, 300));
}
})(window.jsinput = window.jsinput || false);