diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py
index 79fde4f6ae..9dfd50836f 100644
--- a/common/lib/capa/capa/inputtypes.py
+++ b/common/lib/capa/capa/inputtypes.py
@@ -456,11 +456,9 @@ 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
+ Inputtype for general javascript inputs. Intended to be used with
customresponse.
- Loads in a sandboxed iframe to help prevent css and js conflicts between
+ Loads in a sandboxed iframe to help prevent css and js conflicts between
frame and top-level window.
iframe sandbox whitelist:
@@ -478,7 +476,8 @@ class JSInput(InputTypeBase):
height="500"
width="400"/>
- See the documentation in the /doc/public folder for more information.
+ See the documentation in docs/data/source/course_data_formats/jsinput.rst
+ for more information.
"""
template = "jsinput.html"
@@ -498,12 +497,16 @@ class JSInput(InputTypeBase):
Attribute('set_statefn', None), # Function to call iframe to
# set state
Attribute('width', "400"), # iframe width
- Attribute('height', "300") # iframe height
+ Attribute('height', "300"), # iframe height
+ Attribute('sop', None) # SOP will be relaxed only if this
+ # attribute is set to false.
]
def _extra_context(self):
context = {
- 'applet_loader': '{static_url}js/capa/src/jsinput.js'.format(
+ 'jschannel_loader': '{static_url}js/capa/src/jschannel.js'.format(
+ static_url=self.system.STATIC_URL),
+ 'jsinput_loader': '{static_url}js/capa/src/jsinput.js'.format(
static_url=self.system.STATIC_URL),
'saved_state': self.value
}
diff --git a/common/lib/capa/capa/templates/jsinput.html b/common/lib/capa/capa/templates/jsinput.html
index 3948e770d1..6ad0ea7b77 100644
--- a/common/lib/capa/capa/templates/jsinput.html
+++ b/common/lib/capa/capa/templates/jsinput.html
@@ -9,10 +9,14 @@
% if set_statefn:
data-setstate="${set_statefn}"
% endif
+ % if sop:
+ data-sop="${sop}"
+ % endif
+ data-processed="false"
>
-
-
+
+
% if status == 'unsubmitted':
% elif status == 'correct':
diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
index 2dbfe5a90b..967b980179 100644
--- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
@@ -151,7 +151,7 @@ class @Problem
# 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
+ # 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.
@@ -159,18 +159,23 @@ class @Problem
# 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: =>
+ check_save_waitfor: (callback) =>
for inp in @inputs
if ($(inp).is("input[waitfor]"))
try
- $(inp).data("waitfor")()
- @refreshAnswers()
+ $(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
###
@@ -254,7 +259,10 @@ class @Problem
$.ajaxWithPrefix("#{@url}/problem_check", settings)
check: =>
- @check_waitfor()
+ if not @check_save_waitfor(@check_internal)
+ @check_internal()
+
+ check_internal: =>
Logger.log 'problem_check', @answers
# Segment.io
@@ -334,6 +342,10 @@ class @Problem
@el.find('.capa_alert').css(opacity: 0).animate(opacity: 1, 700)
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
diff --git a/common/lib/xmodule/xmodule/templates/problem/jsinput_response.yaml b/common/lib/xmodule/xmodule/templates/problem/jsinput_response.yaml
new file mode 100644
index 0000000000..bd7c622cd5
--- /dev/null
+++ b/common/lib/xmodule/xmodule/templates/problem/jsinput_response.yaml
@@ -0,0 +1,44 @@
+---
+metadata:
+ display_name: Custom Javascript Display and Grading
+ markdown: !!null
+data: |
+
+
+
+ The shapes below can be selected (yellow) or unselected (cyan).
+ Clicking on them repeatedly will cycle through these two states.
+
+
+ If the cone is selected (and not the cube), a correct answer will be
+ generated after pressing "Check". Clicking on either "Check" or "Save"
+ will register the current state.
+
+
+
+
+
\ No newline at end of file
diff --git a/common/static/js/capa/fixtures/jsinput.html b/common/static/js/capa/fixtures/jsinput.html
new file mode 100644
index 0000000000..87feaaaffe
--- /dev/null
+++ b/common/static/js/capa/fixtures/jsinput.html
@@ -0,0 +1,50 @@
+
+
+
\ No newline at end of file
diff --git a/common/static/js/capa/spec/jsinput_spec.js b/common/static/js/capa/spec/jsinput_spec.js
index a4a4f6e57d..b857972fb4 100644
--- a/common/static/js/capa/spec/jsinput_spec.js
+++ b/common/static/js/capa/spec/jsinput_spec.js
@@ -1,70 +1,30 @@
-xdescribe("A jsinput has:", function () {
-
+describe("JSInput", function() {
beforeEach(function () {
- $('#fixture').remove();
- $.ajax({
- async: false,
- url: 'mainfixture.html',
- success: function(data) {
- $('body').append($(data));
- }
+ loadFixtures('js/capa/fixtures/jsinput.html');
+ });
+
+ it('sets all data-processed attributes to true on first load', function() {
+ var sections = $(document).find('section[id="inputtype_"]');
+ JSInput.walkDOM();
+ sections.each(function(index, section) {
+ expect(section.attr('data-processed')).toEqual('true');
});
});
-
-
- 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();
-
- });
- });
+ it('sets the data-processed attribute to true on subsequent load', function() {
+ var section1 = $(document).find('section[id="inputtype_1"]'),
+ section2 = $(document).find('section[id="inputtype_2"]');
+ section1.attr('data-processed', false);
+ JSInput.walkDOM();
+ expect(section1.attr('data-processed')).toEqual('true');
+ expect(section2.attr('data-processed')).toEqual('true');
});
-
- 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);
- }
+ it('sets the waitfor attribute to its update function', function() {
+ var inputFields = $(document).find('input[id="input_"]');
+ JSInput.walkDOM();
+ inputFields.each(function(index, inputField) {
+ expect(inputField.data('waitfor')).toBeDefined();
});
});
-})
+});
diff --git a/common/static/js/capa/spec/mainfixture.html b/common/static/js/capa/spec/mainfixture.html
deleted file mode 100644
index c43b027c70..0000000000
--- a/common/static/js/capa/spec/mainfixture.html
+++ /dev/null
@@ -1,118 +0,0 @@
-
-
- JSinput jasmine test
-
-
-
-
-
-
diff --git a/common/static/js/capa/src/jschannel.js b/common/static/js/capa/src/jschannel.js
new file mode 100644
index 0000000000..666cce9624
--- /dev/null
+++ b/common/static/js/capa/src/jschannel.js
@@ -0,0 +1,788 @@
+/*
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
+ This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+ As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+ "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+ An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+ A "Combined Work" is a work produced by combining or linking an
+Application with the Library. The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+ The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+ The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+ You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+ If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+ a) under this License, provided that you make a good faith effort to
+ ensure that, in the event an Application does not supply the
+ function or data, the facility still operates, and performs
+ whatever part of its purpose remains meaningful, or
+
+ b) under the GNU GPL, with none of the additional permissions of
+ this License applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+ The object code form of an Application may incorporate material from
+a header file that is part of the Library. You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+ a) Give prominent notice with each copy of the object code that the
+ Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the object code with a copy of the GNU GPL and this license
+ document.
+
+ 4. Combined Works.
+
+ You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+ a) Give prominent notice with each copy of the Combined Work that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
+ document.
+
+ c) For a Combined Work that displays copyright notices during
+ execution, include the copyright notice for the Library among
+ these notices, as well as a reference directing the user to the
+ copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+ 0) Convey the Minimal Corresponding Source under the terms of this
+ License, and the Corresponding Application Code in a form
+ suitable for, and under terms that permit, the user to
+ recombine or relink the Application with a modified version of
+ the Linked Version to produce a modified Combined Work, in the
+ manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.
+
+ 1) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (a) uses at run time
+ a copy of the Library already present on the user's computer
+ system, and (b) will operate properly with a modified version
+ of the Library that is interface-compatible with the Linked
+ Version.
+
+ e) Provide Installation Information, but only if you would otherwise
+ be required to provide such information under section 6 of the
+ GNU GPL, and only to the extent that such information is
+ necessary to install and execute a modified version of the
+ Combined Work produced by recombining or relinking the
+ Application with a modified version of the Linked Version. (If
+ you use option 4d0, the Installation Information must accompany
+ the Minimal Corresponding Source and Corresponding Application
+ Code. If you use option 4d1, you must provide the Installation
+ Information in the manner specified by section 6 of the GNU GPL
+ for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+ You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+ a) Accompany the combined library with a copy of the same work based
+ on the Library, uncombined with any other library facilities,
+ conveyed under the terms of this License.
+
+ b) Give prominent notice with the combined library that part of it
+ is a work based on the Library, and explaining where to find the
+ accompanying uncombined form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+ The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+ If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
+*/
+
+/*
+ * js_channel is a very lightweight abstraction on top of
+ * postMessage which defines message formats and semantics
+ * to support interactions more rich than just message passing
+ * js_channel supports:
+ * + query/response - traditional rpc
+ * + query/update/response - incremental async return of results
+ * to a query
+ * + notifications - fire and forget
+ * + error handling
+ *
+ * js_channel is based heavily on json-rpc, but is focused at the
+ * problem of inter-iframe RPC.
+ *
+ * Message types:
+ * There are 5 types of messages that can flow over this channel,
+ * and you may determine what type of message an object is by
+ * examining its parameters:
+ * 1. Requests
+ * + integer id
+ * + string method
+ * + (optional) any params
+ * 2. Callback Invocations (or just "Callbacks")
+ * + integer id
+ * + string callback
+ * + (optional) params
+ * 3. Error Responses (or just "Errors)
+ * + integer id
+ * + string error
+ * + (optional) string message
+ * 4. Responses
+ * + integer id
+ * + (optional) any result
+ * 5. Notifications
+ * + string method
+ * + (optional) any params
+ */
+
+;var Channel = (function() {
+ "use strict";
+
+ // current transaction id, start out at a random *odd* number between 1 and a million
+ // There is one current transaction counter id per page, and it's shared between
+ // channel instances. That means of all messages posted from a single javascript
+ // evaluation context, we'll never have two with the same id.
+ var s_curTranId = Math.floor(Math.random()*1000001);
+
+ // no two bound channels in the same javascript evaluation context may have the same origin, scope, and window.
+ // futher if two bound channels have the same window and scope, they may not have *overlapping* origins
+ // (either one or both support '*'). This restriction allows a single onMessage handler to efficiently
+ // route messages based on origin and scope. The s_boundChans maps origins to scopes, to message
+ // handlers. Request and Notification messages are routed using this table.
+ // Finally, channels are inserted into this table when built, and removed when destroyed.
+ var s_boundChans = { };
+
+ // add a channel to s_boundChans, throwing if a dup exists
+ function s_addBoundChan(win, origin, scope, handler) {
+ function hasWin(arr) {
+ for (var i = 0; i < arr.length; i++) if (arr[i].win === win) return true;
+ return false;
+ }
+
+ // does she exist?
+ var exists = false;
+
+
+ if (origin === '*') {
+ // we must check all other origins, sadly.
+ for (var k in s_boundChans) {
+ if (!s_boundChans.hasOwnProperty(k)) continue;
+ if (k === '*') continue;
+ if (typeof s_boundChans[k][scope] === 'object') {
+ exists = hasWin(s_boundChans[k][scope]);
+ if (exists) break;
+ }
+ }
+ } else {
+ // we must check only '*'
+ if ((s_boundChans['*'] && s_boundChans['*'][scope])) {
+ exists = hasWin(s_boundChans['*'][scope]);
+ }
+ if (!exists && s_boundChans[origin] && s_boundChans[origin][scope])
+ {
+ exists = hasWin(s_boundChans[origin][scope]);
+ }
+ }
+ if (exists) throw "A channel is already bound to the same window which overlaps with origin '"+ origin +"' and has scope '"+scope+"'";
+
+ if (typeof s_boundChans[origin] != 'object') s_boundChans[origin] = { };
+ if (typeof s_boundChans[origin][scope] != 'object') s_boundChans[origin][scope] = [ ];
+ s_boundChans[origin][scope].push({win: win, handler: handler});
+ }
+
+ function s_removeBoundChan(win, origin, scope) {
+ var arr = s_boundChans[origin][scope];
+ for (var i = 0; i < arr.length; i++) {
+ if (arr[i].win === win) {
+ arr.splice(i,1);
+ }
+ }
+ if (s_boundChans[origin][scope].length === 0) {
+ delete s_boundChans[origin][scope];
+ }
+ }
+
+ function s_isArray(obj) {
+ if (Array.isArray) return Array.isArray(obj);
+ else {
+ return (obj.constructor.toString().indexOf("Array") != -1);
+ }
+ }
+
+ // No two outstanding outbound messages may have the same id, period. Given that, a single table
+ // mapping "transaction ids" to message handlers, allows efficient routing of Callback, Error, and
+ // Response messages. Entries are added to this table when requests are sent, and removed when
+ // responses are received.
+ var s_transIds = { };
+
+ // class singleton onMessage handler
+ // this function is registered once and all incoming messages route through here. This
+ // arrangement allows certain efficiencies, message data is only parsed once and dispatch
+ // is more efficient, especially for large numbers of simultaneous channels.
+ var s_onMessage = function(e) {
+ try {
+ var m = JSON.parse(e.data);
+ if (typeof m !== 'object' || m === null) throw "malformed";
+ } catch(e) {
+ // just ignore any posted messages that do not consist of valid JSON
+ return;
+ }
+
+ var w = e.source;
+ var o = e.origin;
+ var s, i, meth;
+
+ if (typeof m.method === 'string') {
+ var ar = m.method.split('::');
+ if (ar.length == 2) {
+ s = ar[0];
+ meth = ar[1];
+ } else {
+ meth = m.method;
+ }
+ }
+
+ if (typeof m.id !== 'undefined') i = m.id;
+
+ // w is message source window
+ // o is message origin
+ // m is parsed message
+ // s is message scope
+ // i is message id (or undefined)
+ // meth is unscoped method name
+ // ^^ based on these factors we can route the message
+
+ // if it has a method it's either a notification or a request,
+ // route using s_boundChans
+ if (typeof meth === 'string') {
+ var delivered = false;
+ if (s_boundChans[o] && s_boundChans[o][s]) {
+ for (var j = 0; j < s_boundChans[o][s].length; j++) {
+ if (s_boundChans[o][s][j].win === w) {
+ s_boundChans[o][s][j].handler(o, meth, m);
+ delivered = true;
+ break;
+ }
+ }
+ }
+
+ if (!delivered && s_boundChans['*'] && s_boundChans['*'][s]) {
+ for (var j = 0; j < s_boundChans['*'][s].length; j++) {
+ if (s_boundChans['*'][s][j].win === w) {
+ s_boundChans['*'][s][j].handler(o, meth, m);
+ break;
+ }
+ }
+ }
+ }
+ // otherwise it must have an id (or be poorly formed
+ else if (typeof i != 'undefined') {
+ if (s_transIds[i]) s_transIds[i](o, meth, m);
+ }
+ };
+
+ // Setup postMessage event listeners
+ if (window.addEventListener) window.addEventListener('message', s_onMessage, false);
+ else if(window.attachEvent) window.attachEvent('onmessage', s_onMessage);
+
+ /* a messaging channel is constructed from a window and an origin.
+ * the channel will assert that all messages received over the
+ * channel match the origin
+ *
+ * Arguments to Channel.build(cfg):
+ *
+ * cfg.window - the remote window with which we'll communicate
+ * cfg.origin - the expected origin of the remote window, may be '*'
+ * which matches any origin
+ * cfg.scope - the 'scope' of messages. a scope string that is
+ * prepended to message names. local and remote endpoints
+ * of a single channel must agree upon scope. Scope may
+ * not contain double colons ('::').
+ * cfg.debugOutput - A boolean value. If true and window.console.log is
+ * a function, then debug strings will be emitted to that
+ * function.
+ * cfg.debugOutput - A boolean value. If true and window.console.log is
+ * a function, then debug strings will be emitted to that
+ * function.
+ * cfg.postMessageObserver - A function that will be passed two arguments,
+ * an origin and a message. It will be passed these immediately
+ * before messages are posted.
+ * cfg.gotMessageObserver - A function that will be passed two arguments,
+ * an origin and a message. It will be passed these arguments
+ * immediately after they pass scope and origin checks, but before
+ * they are processed.
+ * cfg.onReady - A function that will be invoked when a channel becomes "ready",
+ * this occurs once both sides of the channel have been
+ * instantiated and an application level handshake is exchanged.
+ * the onReady function will be passed a single argument which is
+ * the channel object that was returned from build().
+ */
+ return {
+ build: function(cfg) {
+ var debug = function(m) {
+ if (cfg.debugOutput && window.console && window.console.log) {
+ // try to stringify, if it doesn't work we'll let javascript's built in toString do its magic
+ try { if (typeof m !== 'string') m = JSON.stringify(m); } catch(e) { }
+ console.log("["+chanId+"] " + m);
+ }
+ };
+
+ /* browser capabilities check */
+ if (!window.postMessage) throw("jschannel cannot run this browser, no postMessage");
+ if (!window.JSON || !window.JSON.stringify || ! window.JSON.parse) {
+ throw("jschannel cannot run this browser, no JSON parsing/serialization");
+ }
+
+ /* basic argument validation */
+ if (typeof cfg != 'object') throw("Channel build invoked without a proper object argument");
+
+ if (!cfg.window || !cfg.window.postMessage) throw("Channel.build() called without a valid window argument");
+
+ /* we'd have to do a little more work to be able to run multiple channels that intercommunicate the same
+ * window... Not sure if we care to support that */
+ if (window === cfg.window) throw("target window is same as present window -- not allowed");
+
+ // let's require that the client specify an origin. if we just assume '*' we'll be
+ // propagating unsafe practices. that would be lame.
+ var validOrigin = false;
+ if (typeof cfg.origin === 'string') {
+ var oMatch;
+ if (cfg.origin === "*") validOrigin = true;
+ // allow valid domains under http and https. Also, trim paths off otherwise valid origins.
+ else if (null !== (oMatch = cfg.origin.match(/^https?:\/\/(?:[-a-zA-Z0-9_\.])+(?::\d+)?/))) {
+ cfg.origin = oMatch[0].toLowerCase();
+ validOrigin = true;
+ }
+ }
+
+ if (!validOrigin) throw ("Channel.build() called with an invalid origin");
+
+ if (typeof cfg.scope !== 'undefined') {
+ if (typeof cfg.scope !== 'string') throw 'scope, when specified, must be a string';
+ if (cfg.scope.split('::').length > 1) throw "scope may not contain double colons: '::'";
+ }
+
+ /* private variables */
+ // generate a random and psuedo unique id for this channel
+ var chanId = (function () {
+ var text = "";
+ var alpha = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+ for(var i=0; i < 5; i++) text += alpha.charAt(Math.floor(Math.random() * alpha.length));
+ return text;
+ })();
+
+ // registrations: mapping method names to call objects
+ var regTbl = { };
+ // current oustanding sent requests
+ var outTbl = { };
+ // current oustanding received requests
+ var inTbl = { };
+ // are we ready yet? when false we will block outbound messages.
+ var ready = false;
+ var pendingQueue = [ ];
+
+ var createTransaction = function(id,origin,callbacks) {
+ var shouldDelayReturn = false;
+ var completed = false;
+
+ return {
+ origin: origin,
+ invoke: function(cbName, v) {
+ // verify in table
+ if (!inTbl[id]) throw "attempting to invoke a callback of a nonexistent transaction: " + id;
+ // verify that the callback name is valid
+ var valid = false;
+ for (var i = 0; i < callbacks.length; i++) if (cbName === callbacks[i]) { valid = true; break; }
+ if (!valid) throw "request supports no such callback '" + cbName + "'";
+
+ // send callback invocation
+ postMessage({ id: id, callback: cbName, params: v});
+ },
+ error: function(error, message) {
+ completed = true;
+ // verify in table
+ if (!inTbl[id]) throw "error called for nonexistent message: " + id;
+
+ // remove transaction from table
+ delete inTbl[id];
+
+ // send error
+ postMessage({ id: id, error: error, message: message });
+ },
+ complete: function(v) {
+ completed = true;
+ // verify in table
+ if (!inTbl[id]) throw "complete called for nonexistent message: " + id;
+ // remove transaction from table
+ delete inTbl[id];
+ // send complete
+ postMessage({ id: id, result: v });
+ },
+ delayReturn: function(delay) {
+ if (typeof delay === 'boolean') {
+ shouldDelayReturn = (delay === true);
+ }
+ return shouldDelayReturn;
+ },
+ completed: function() {
+ return completed;
+ }
+ };
+ };
+
+ var setTransactionTimeout = function(transId, timeout, method) {
+ return window.setTimeout(function() {
+ if (outTbl[transId]) {
+ // XXX: what if client code raises an exception here?
+ var msg = "timeout (" + timeout + "ms) exceeded on method '" + method + "'";
+ (1,outTbl[transId].error)("timeout_error", msg);
+ delete outTbl[transId];
+ delete s_transIds[transId];
+ }
+ }, timeout);
+ };
+
+ var onMessage = function(origin, method, m) {
+ // if an observer was specified at allocation time, invoke it
+ if (typeof cfg.gotMessageObserver === 'function') {
+ // pass observer a clone of the object so that our
+ // manipulations are not visible (i.e. method unscoping).
+ // This is not particularly efficient, but then we expect
+ // that message observers are primarily for debugging anyway.
+ try {
+ cfg.gotMessageObserver(origin, m);
+ } catch (e) {
+ debug("gotMessageObserver() raised an exception: " + e.toString());
+ }
+ }
+
+ // now, what type of message is this?
+ if (m.id && method) {
+ // a request! do we have a registered handler for this request?
+ if (regTbl[method]) {
+ var trans = createTransaction(m.id, origin, m.callbacks ? m.callbacks : [ ]);
+ inTbl[m.id] = { };
+ try {
+ // callback handling. we'll magically create functions inside the parameter list for each
+ // callback
+ if (m.callbacks && s_isArray(m.callbacks) && m.callbacks.length > 0) {
+ for (var i = 0; i < m.callbacks.length; i++) {
+ var path = m.callbacks[i];
+ var obj = m.params;
+ var pathItems = path.split('/');
+ for (var j = 0; j < pathItems.length - 1; j++) {
+ var cp = pathItems[j];
+ if (typeof obj[cp] !== 'object') obj[cp] = { };
+ obj = obj[cp];
+ }
+ obj[pathItems[pathItems.length - 1]] = (function() {
+ var cbName = path;
+ return function(params) {
+ return trans.invoke(cbName, params);
+ };
+ })();
+ }
+ }
+ var resp = regTbl[method](trans, m.params);
+ if (!trans.delayReturn() && !trans.completed()) trans.complete(resp);
+ } catch(e) {
+ // automagic handling of exceptions:
+ var error = "runtime_error";
+ var message = null;
+ // * if it's a string then it gets an error code of 'runtime_error' and string is the message
+ if (typeof e === 'string') {
+ message = e;
+ } else if (typeof e === 'object') {
+ // either an array or an object
+ // * if it's an array of length two, then array[0] is the code, array[1] is the error message
+ if (e && s_isArray(e) && e.length == 2) {
+ error = e[0];
+ message = e[1];
+ }
+ // * if it's an object then we'll look form error and message parameters
+ else if (typeof e.error === 'string') {
+ error = e.error;
+ if (!e.message) message = "";
+ else if (typeof e.message === 'string') message = e.message;
+ else e = e.message; // let the stringify/toString message give us a reasonable verbose error string
+ }
+ }
+
+ // message is *still* null, let's try harder
+ if (message === null) {
+ try {
+ message = JSON.stringify(e);
+ /* On MSIE8, this can result in 'out of memory', which
+ * leaves message undefined. */
+ if (typeof(message) == 'undefined')
+ message = e.toString();
+ } catch (e2) {
+ message = e.toString();
+ }
+ }
+
+ trans.error(error,message);
+ }
+ }
+ } else if (m.id && m.callback) {
+ if (!outTbl[m.id] ||!outTbl[m.id].callbacks || !outTbl[m.id].callbacks[m.callback])
+ {
+ debug("ignoring invalid callback, id:"+m.id+ " (" + m.callback +")");
+ } else {
+ // XXX: what if client code raises an exception here?
+ outTbl[m.id].callbacks[m.callback](m.params);
+ }
+ } else if (m.id) {
+ if (!outTbl[m.id]) {
+ debug("ignoring invalid response: " + m.id);
+ } else {
+ // XXX: what if client code raises an exception here?
+ if (m.error) {
+ (1,outTbl[m.id].error)(m.error, m.message);
+ } else {
+ if (m.result !== undefined) (1,outTbl[m.id].success)(m.result);
+ else (1,outTbl[m.id].success)();
+ }
+ delete outTbl[m.id];
+ delete s_transIds[m.id];
+ }
+ } else if (method) {
+ // tis a notification.
+ if (regTbl[method]) {
+ // yep, there's a handler for that.
+ // transaction has only origin for notifications.
+ regTbl[method]({ origin: origin }, m.params);
+ // if the client throws, we'll just let it bubble out
+ // what can we do? Also, here we'll ignore return values
+ }
+ }
+ };
+
+ // now register our bound channel for msg routing
+ s_addBoundChan(cfg.window, cfg.origin, ((typeof cfg.scope === 'string') ? cfg.scope : ''), onMessage);
+
+ // scope method names based on cfg.scope specified when the Channel was instantiated
+ var scopeMethod = function(m) {
+ if (typeof cfg.scope === 'string' && cfg.scope.length) m = [cfg.scope, m].join("::");
+ return m;
+ };
+
+ // a small wrapper around postmessage whose primary function is to handle the
+ // case that clients start sending messages before the other end is "ready"
+ var postMessage = function(msg, force) {
+ if (!msg) throw "postMessage called with null message";
+
+ // delay posting if we're not ready yet.
+ var verb = (ready ? "post " : "queue ");
+ debug(verb + " message: " + JSON.stringify(msg));
+ if (!force && !ready) {
+ pendingQueue.push(msg);
+ } else {
+ if (typeof cfg.postMessageObserver === 'function') {
+ try {
+ cfg.postMessageObserver(cfg.origin, msg);
+ } catch (e) {
+ debug("postMessageObserver() raised an exception: " + e.toString());
+ }
+ }
+
+ cfg.window.postMessage(JSON.stringify(msg), cfg.origin);
+ }
+ };
+
+ var onReady = function(trans, type) {
+ debug('ready msg received');
+ if (ready) throw "received ready message while in ready state. help!";
+
+ if (type === 'ping') {
+ chanId += '-R';
+ } else {
+ chanId += '-L';
+ }
+
+ obj.unbind('__ready'); // now this handler isn't needed any more.
+ ready = true;
+ debug('ready msg accepted.');
+
+ if (type === 'ping') {
+ obj.notify({ method: '__ready', params: 'pong' });
+ }
+
+ // flush queue
+ while (pendingQueue.length) {
+ postMessage(pendingQueue.pop());
+ }
+
+ // invoke onReady observer if provided
+ if (typeof cfg.onReady === 'function') cfg.onReady(obj);
+ };
+
+ var obj = {
+ // tries to unbind a bound message handler. returns false if not possible
+ unbind: function (method) {
+ if (regTbl[method]) {
+ if (!(delete regTbl[method])) throw ("can't delete method: " + method);
+ return true;
+ }
+ return false;
+ },
+ bind: function (method, cb) {
+ if (!method || typeof method !== 'string') throw "'method' argument to bind must be string";
+ if (!cb || typeof cb !== 'function') throw "callback missing from bind params";
+
+ if (regTbl[method]) throw "method '"+method+"' is already bound!";
+ regTbl[method] = cb;
+ return this;
+ },
+ call: function(m) {
+ if (!m) throw 'missing arguments to call function';
+ if (!m.method || typeof m.method !== 'string') throw "'method' argument to call must be string";
+ if (!m.success || typeof m.success !== 'function') throw "'success' callback missing from call";
+
+ // now it's time to support the 'callback' feature of jschannel. We'll traverse the argument
+ // object and pick out all of the functions that were passed as arguments.
+ var callbacks = { };
+ var callbackNames = [ ];
+ var seen = [ ];
+
+ var pruneFunctions = function (path, obj) {
+ if (seen.indexOf(obj) >= 0) {
+ throw "params cannot be a recursive data structure"
+ }
+ seen.push(obj);
+
+ if (typeof obj === 'object') {
+ for (var k in obj) {
+ if (!obj.hasOwnProperty(k)) continue;
+ var np = path + (path.length ? '/' : '') + k;
+ if (typeof obj[k] === 'function') {
+ callbacks[np] = obj[k];
+ callbackNames.push(np);
+ delete obj[k];
+ } else if (typeof obj[k] === 'object') {
+ pruneFunctions(np, obj[k]);
+ }
+ }
+ }
+ };
+ pruneFunctions("", m.params);
+
+ // build a 'request' message and send it
+ var msg = { id: s_curTranId, method: scopeMethod(m.method), params: m.params };
+ if (callbackNames.length) msg.callbacks = callbackNames;
+
+ if (m.timeout)
+ // XXX: This function returns a timeout ID, but we don't do anything with it.
+ // We might want to keep track of it so we can cancel it using clearTimeout()
+ // when the transaction completes.
+ setTransactionTimeout(s_curTranId, m.timeout, scopeMethod(m.method));
+
+ // insert into the transaction table
+ outTbl[s_curTranId] = { callbacks: callbacks, error: m.error, success: m.success };
+ s_transIds[s_curTranId] = onMessage;
+
+ // increment current id
+ s_curTranId++;
+
+ postMessage(msg);
+ },
+ notify: function(m) {
+ if (!m) throw 'missing arguments to notify function';
+ if (!m.method || typeof m.method !== 'string') throw "'method' argument to notify must be string";
+
+ // no need to go into any transaction table
+ postMessage({ method: scopeMethod(m.method), params: m.params });
+ },
+ destroy: function () {
+ s_removeBoundChan(cfg.window, cfg.origin, ((typeof cfg.scope === 'string') ? cfg.scope : ''));
+ if (window.removeEventListener) window.removeEventListener('message', onMessage, false);
+ else if(window.detachEvent) window.detachEvent('onmessage', onMessage);
+ ready = false;
+ regTbl = { };
+ inTbl = { };
+ outTbl = { };
+ cfg.origin = null;
+ pendingQueue = [ ];
+ debug("channel destroyed");
+ chanId = "";
+ }
+ };
+
+ obj.bind('__ready', onReady);
+ setTimeout(function() {
+ postMessage({ method: scopeMethod('__ready'), params: "ping" }, true);
+ }, 0);
+
+ return obj;
+ }
+ };
+})();
diff --git a/common/static/js/capa/src/jsinput.js b/common/static/js/capa/src/jsinput.js
index 9d3fde32fc..d54af46bbe 100644
--- a/common/static/js/capa/src/jsinput.js
+++ b/common/static/js/capa/src/jsinput.js
@@ -1,37 +1,28 @@
-(function (jsinput, undefined) {
+/*
+ * JSChannel (https://github.com/mozilla/jschannel) will be loaded prior to this
+ * script. We will use it use to let JSInput call 'gradeFn', and eventually
+ * 'stateGetter' & 'stateSetter' in the iframe's content even if it hasn't the
+ * same origin, therefore bypassing SOP:
+ * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Same_origin_policy_for_JavaScript
+ */
+
+var JSInput = (function ($, 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){
+ // _deepKey(obj, "an.example") -> obj["an"]["example"]
+ function _deepKey(obj, path){
for (var i = 0, p=path.split('.'), len = p.length; i < len; i++){
obj = obj[p[i]];
}
@@ -42,115 +33,141 @@
/* END Utils */
-
-
- function jsinputConstructor(spec) {
+ function jsinputConstructor(elem) {
// 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;
+ var section = $(elem).parent().find('section[class="jsinput"]'),
+ sectionAttr = function (e) { return $(section).attr(e); },
+ iframe = $(elem).find('iframe[name^="iframe_"]').get(0),
+ cWindow = iframe.contentWindow,
+ path = iframe.src.substring(0, iframe.src.lastIndexOf("/")+1),
+ // Get the hidden input field to pass to customresponse
+ inputField = $(elem).parent().find('input[id^="input_"]'),
+ // Get the grade function name
+ gradeFn = sectionAttr("data"),
+ // Get state getter
+ stateGetter = sectionAttr("data-getstate"),
+ // Get state setter
+ stateSetter = sectionAttr("data-setstate"),
+ // Get stored state
+ storedState = sectionAttr("data-stored"),
+ // Bypass single-origin policy only if this attribute is "false"
+ // In that case, use JSChannel to do so.
+ sop = sectionAttr("data-sop"),
+ channel;
+
+ sop = (sop !== "false");
- // 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;
- };
+ if (!sop) {
+ channel = Channel.build({
+ window: cWindow,
+ origin: path,
+ scope: "JSInput"
+ });
+ }
/* Public methods */
+
+ // Only one public method that updates the hidden input field.
+ var update = function (callback) {
+ var answer, state, store;
- that.update = update;
-
+ if (sop) {
+ answer = _deepKey(cWindow, gradeFn)();
+ // Setting state presumes getting state, so don't get state
+ // unless set state is defined.
+ if (stateGetter && stateSetter) {
+ state = unescape(_deepKey(cWindow, stateGetter)());
+ store = {
+ answer: answer,
+ state: state
+ };
+ inputField.val(JSON.stringify(store));
+ } else {
+ inputField.val(answer);
+ }
+ callback();
+ } else {
+ channel.call({
+ method: "getGrade",
+ params: "",
+ success: function(val) {
+ answer = decodeURI(val.toString());
+ // Setting state presumes getting state, so don't get
+ // state unless set state is defined.
+ if (stateGetter && stateSetter) {
+ channel.call({
+ method: "getState",
+ params: "",
+ success: function(val) {
+ state = decodeURI(val.toString());
+ store = {
+ answer: answer,
+ state: state
+ };
+ inputField.val(JSON.stringify(store));
+ callback();
+ }
+ });
+ } else {
+ inputField.val(answer);
+ callback();
+ }
+ }
+ });
+ }
+ };
/* 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();
+ inputField.data('waitfor', update);
// Check whether application takes in state and there is a saved
- // state to give it. If getStateSetter is specified but calling it
+ // state to give it. If stateSetter is specified but calling it
// fails, wait and try again, since the iframe might still be
// loading.
- if (getStateSetter && getStoredState) {
- var sval, jsonVal;
+ if (stateSetter && storedState) {
+ var stateValue, jsonValue;
try {
- jsonVal = JSON.parse(getStoredState);
+ jsonValue = JSON.parse(storedState);
} catch (err) {
- jsonVal = getStoredState;
+ jsonValue = storedState;
}
- if (typeof(jsonVal) === "object") {
- sval = jsonVal["state"];
+ if (typeof(jsonValue) === "object") {
+ stateValue = jsonValue["state"];
} else {
- sval = jsonVal;
+ stateValue = jsonValue;
}
-
// 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.)
+ // 200 ms and 5 times are arbitrary but this has functioned with the
+ // only application that has ever used JSInput, jsVGL. Something
+ // more sturdy should be put in place.
function whileloop(n) {
if (n < 5){
try {
- _deepKey(cWindow, getStateSetter)(sval);
+ if (sop) {
+ _deepKey(cWindow, stateSetter)(stateValue);
+ } else {
+ channel.call({
+ method: "setState",
+ params: stateValue,
+ success: function() {
+ }
+ });
+ }
} catch (err) {
setTimeout(whileloop(n+1), 200);
}
@@ -160,40 +177,44 @@
}
}
whileloop(0);
-
}
-
-
- return that;
}
-
function walkDOM() {
- var newid;
+ var dataProcessed, all;
- // Find all jsinput elements, and create a jsinput object for each one
- var all = $(document).find('section[class="jsinput"]');
+ // Find all jsinput elements
+ allSections = $('section.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,
- });
+ // When a JSInput problem loads, its data-processed attribute is false,
+ // so the jsconstructor will be called for it.
+ // The constructor will not be called again on subsequent reruns of
+ // this file by other JSInput. Only if it is reloaded, either with the
+ // rest of the page or when it is submitted, will this constructor be
+ // called again.
+ allSections.each(function(index, value) {
+ dataProcessed = ($(value).attr("data-processed") === "true");
+ if (!dataProcessed) {
+ jsinputConstructor(value);
+ $(value).attr("data-processed", 'true');
}
});
}
// This is ugly, but without a timeout pages with multiple/heavy jsinputs
// don't load properly.
+ // 300 ms is arbitrary but this has functioned with the only application
+ // that has ever used JSInput, jsVGL. Something more sturdy should be put in
+ // place.
if ($.isReady) {
setTimeout(walkDOM, 300);
} else {
$(document).ready(setTimeout(walkDOM, 300));
}
-})(window.jsinput = window.jsinput || false);
+ return {
+ jsinputConstructor: jsinputConstructor,
+ walkDOM: walkDOM
+ };
+
+})(window.jQuery);
\ No newline at end of file
diff --git a/common/static/js_test.yml b/common/static/js_test.yml
index 81879153f3..31929401f0 100644
--- a/common/static/js_test.yml
+++ b/common/static/js_test.yml
@@ -42,11 +42,13 @@ lib_paths:
src_paths:
- coffee/src
- js/src
+ - js/capa/src
# Paths to spec (test) JavaScript files
spec_paths:
- coffee/spec
- js/spec
+ - js/capa/spec
# Regular expressions used to exclude *.js files from
# appearing in the test runner page.
@@ -72,4 +74,5 @@ spec_paths:
# plus the path to the file (relative to this YAML file)
fixture_paths:
- js/fixtures
+ - js/capa/fixtures
diff --git a/docs/data/source/course_data_formats/jsinput.rst b/docs/data/source/course_data_formats/jsinput.rst
index 5cf043a3ce..23a8c06964 100644
--- a/docs/data/source/course_data_formats/jsinput.rst
+++ b/docs/data/source/course_data_formats/jsinput.rst
@@ -1,12 +1,6 @@
##############################################################################
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