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