Commit ab4dfc24 by jkarni

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

Feature/jkarni/jsinput
parents e477ccee fd6abc88
......@@ -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):
......
......@@ -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
......
<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>
......@@ -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%);
}
......
......@@ -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
......
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);
}
});
});
})
<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>
(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);
##############################################################################
JS Input
##############################################################################
**NOTE**
*Do not use this feature yet! Its attributes and behaviors may change
without any concern for backwards compatibility. Moreover, it has only been
tested in a very limited context. If you absolutely must, contact Julian
(julian@edx.org). When the feature stabilizes, this note will be removed.*
This document explains how to write a JSInput input type. JSInput is meant to
allow problem authors to easily turn working standalone HTML files into
problems that can be integrated into the edX platform. Since it's aim is
flexibility, it can be seen as the input and client-side equivalent of
CustomResponse.
A JSInput input creates an iframe into a static HTML page, and passes the
return value of author-specified functions to the enclosing response type
(generally CustomResponse). JSInput can also stored and retrieve state.
******************************************************************************
Format
******************************************************************************
A jsinput problem looks like this:
.. code-block:: xml
<problem>
<script type="loncapa/python">
def all_true(exp, ans): return ans == "hi"
</script>
<customresponse cfn="all_true">
<jsinput gradefn="gradefn"
height="500"
get_statefn="getstate"
set_statefn="setstate"
html_file="/static/jsinput.html"/>
</customresponse>
</problem>
The accepted attributes are:
============== ============== ========= ==========
Attribute Name Value Type Required? Default
============== ============== ========= ==========
html_file Url string Yes None
gradefn Function name Yes `gradefn`
set_statefn Function name No None
get_statefn Function name No None
height Integer No `500`
width Integer No `400`
============== ============== ========= ==========
******************************************************************************
Required Attributes
******************************************************************************
==============================================================================
html_file
==============================================================================
The `html_file` attribute specifies what html file the iframe will point to. This
should be located in the content directory.
The iframe is created using the sandbox attribute; while popups, scripts, and
pointer locks are allowed, the iframe cannot access its parent's attributes.
The html file should contain an accesible gradefn function. To check whether
the gradefn will be accessible to JSInput, check that, in the console,::
"`gradefn"
Returns the right thing. When used by JSInput, `gradefn` is called with::
`gradefn`.call(`obj`)
Where `obj` is the object-part of `gradefn`. For example, if `gradefn` is
`myprog.myfn`, JSInput will call `myprog.myfun.call(myprog)`. (This is to
ensure "`this`" continues to refer to what `gradefn` expects.)
Aside from that, more or less anything goes. Note that currently there is no
support for inheriting css or javascript from the parent (aside from the
Chrome-only `seamless` attribute, which is set to true by default).
==============================================================================
gradefn
==============================================================================
The `gradefn` attribute specifies the name of the function that will be called
when a user clicks on the "Check" button, and which should return the student's
answer. This answer will (unless both the get_statefn and set_statefn
attributes are also used) be passed as a string to the enclosing response type.
In the customresponse example above, this means cfn will be passed this answer
as `ans`.
If the `gradefn` function throws an exception when a student attempts to
submit a problem, the submission is aborted, and the student receives a generic
alert. The alert can be customised by making the exception name `Waitfor
Exception`; in that case, the alert message will be the exception message.
**IMPORTANT** : the `gradefn` function should not be at all asynchronous, since
this could result in the student's latest answer not being passed correctly.
Moreover, the function should also return promptly, since currently the student
has no indication that her answer is being calculated/produced.
******************************************************************************
Option Attributes
******************************************************************************
The `height` and `width` attributes are straightforward: they specify the
height and width of the iframe. Both are limited by the enclosing DOM elements,
so for instance there is an implicit max-width of around 900.
In the future, JSInput may attempt to make these dimensions match the html
file's dimensions (up to the aforementioned limits), but currently it defaults
to `500` and `400` for `height` and `width`, respectively.
==============================================================================
set_statefn
==============================================================================
Sometimes a problem author will want information about a student's previous
answers ("state") to be saved and reloaded. If the attribute `set_statefn` is
used, the function given as its value will be passed the state as a string
argument whenever there is a state, and the student returns to a problem. It is
the responsibility of the function to then use this state approriately.
The state that is passed is:
1. The previous output of `gradefn` (i.e., the previous answer) if
`get_statefn` is not defined.
2. The previous output of `get_statefn` (see below) otherwise.
It is the responsibility of the iframe to do proper verification of the
argument that it receives via `set_statefn`.
==============================================================================
get_statefn
==============================================================================
Sometimes the state and the answer are quite different. For instance, a problem
that involves using a javascript program that allows the student to alter a
molecule may grade based on the molecule's hidrophobicity, but from the
hidrophobicity it might be incapable of restoring the state. In that case, a
*separate* state may be stored and loaded by `set_statefn`. Note that if
`get_statefn` is defined, the answer (i.e., what is passed to the enclosing
response type) will be a json string with the following format::
{
answer: `[answer string]`
state: `[state string]`
}
It is the responsibility of the enclosing response type to then parse this as
json.
......@@ -29,6 +29,7 @@ Specific Problem Types
course_data_formats/word_cloud/word_cloud.rst
course_data_formats/custom_response.rst
course_data_formats/symbolic_response.rst
course_data_formats/jsinput.rst
Internal Data Formats
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment