diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py
index 4c40a2cd3e..9bb72ad4e1 100644
--- a/common/lib/capa/capa/inputtypes.py
+++ b/common/lib/capa/capa/inputtypes.py
@@ -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:
+
+
+
+ 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):
diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py
index 97319bdb9e..3762c21976 100644
--- a/common/lib/capa/capa/responsetypes.py
+++ b/common/lib/capa/capa/responsetypes.py
@@ -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
diff --git a/common/lib/capa/capa/templates/jsinput.html b/common/lib/capa/capa/templates/jsinput.html
new file mode 100644
index 0000000000..3948e770d1
--- /dev/null
+++ b/common/lib/capa/capa/templates/jsinput.html
@@ -0,0 +1,64 @@
+
+
+
+
+ % if status == 'unsubmitted':
+
+ % elif status == 'correct':
+
+ % elif status == 'incorrect':
+
+ % elif status == 'incomplete':
+
+ % endif
+
+
+
+
+
+
+
+
+ % if status == 'unsubmitted':
+ unanswered
+ % elif status == 'correct':
+ correct
+ % elif status == 'incorrect':
+ incorrect
+ % elif status == 'incomplete':
+ incomplete
+ % endif
+
+
+
+
+
+ % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
+
+ % endif
+
+ % if msg:
+ ${msg|n}
+ % endif
+
diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss
index 819499cbe5..ff40a5f7ca 100644
--- a/common/lib/xmodule/xmodule/css/capa/display.scss
+++ b/common/lib/xmodule/xmodule/css/capa/display.scss
@@ -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%);
}
diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
index 4640f7555d..e29276936b 100644
--- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
@@ -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
diff --git a/common/static/css/capa/jsinput_css.css b/common/static/css/capa/jsinput_css.css
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/static/js/capa/spec/jsinput/jsinput.js b/common/static/js/capa/spec/jsinput/jsinput.js
new file mode 100644
index 0000000000..252bc4df54
--- /dev/null
+++ b/common/static/js/capa/spec/jsinput/jsinput.js
@@ -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);
+ }
+ });
+ });
+})
diff --git a/common/static/js/capa/spec/jsinput/mainfixture.html b/common/static/js/capa/spec/jsinput/mainfixture.html
new file mode 100644
index 0000000000..c43b027c70
--- /dev/null
+++ b/common/static/js/capa/spec/jsinput/mainfixture.html
@@ -0,0 +1,118 @@
+
+
+ JSinput jasmine test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/common/static/js/capa/src/jsinput.js b/common/static/js/capa/src/jsinput.js
new file mode 100644
index 0000000000..9d3fde32fc
--- /dev/null
+++ b/common/static/js/capa/src/jsinput.js
@@ -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);
diff --git a/doc/public/course_data_formats/jsinput.rst b/doc/public/course_data_formats/jsinput.rst
new file mode 100644
index 0000000000..5cf043a3ce
--- /dev/null
+++ b/doc/public/course_data_formats/jsinput.rst
@@ -0,0 +1,151 @@
+##############################################################################
+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
+
+
+
+
+
+
+
+
+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.
diff --git a/doc/public/index.rst b/doc/public/index.rst
index cda3809237..2af091353e 100644
--- a/doc/public/index.rst
+++ b/doc/public/index.rst
@@ -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