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 + + + + +
+ +

+ +

+

+

+ +
+ +
+
+ +
+ + + +
+ +

+ +

+

+

+ +
+ +
+ + 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