Commit a79d79aa by Anton Stupak

Merge pull request #1891 from edx/anton/fix-lti-dnd

LTI: fix reordering bug in Studio
parents 0b161998 2ea16631
......@@ -30,6 +30,5 @@ div.lti {
height: 800px;
display: block;
border: 0px;
overflow-x: hidden;
}
}
/**
* File: lti.js
*
* Purpose: LTI module constructor. Given an LTI element, we process it.
*
*
* Inside the element there is a form. If that form has a valid action
* attribute, then we do one of:
*
* 1.) Submit the form. The results will be shown on the current page in an
* iframe.
* 2.) Attach a handler function to a link which will submit the form. The
* results will be shown in a new window.
*
* The 'open_in_a_new_page' data attribute of the LTI element dictates which of
* the two actions will be performed.
*/
/*
* So the thing to do when working on a motorcycle, as in any other task, is to
* cultivate the peace of mind which does not separate one's self from one's
* surroundings. When that is done successfully, then everything else follows
* naturally. Peace of mind produces right values, right values produce right
* thoughts. Right thoughts produce right actions and right actions produce
* work which will be a material reflection for others to see of the serenity
* at the center of it all.
*
* ~ Robert M. Pirsig
*/
(function (requirejs, require, define) {
// JavaScript LTI XModule
define(
'lti/01_lti.js',
[],
function () {
var LTI = LTIConstructor;
LTI.prototype = {
submitFormHandler: submitFormHandler,
getNewSignature: getNewSignature,
handleAjaxUpdateSignature: handleAjaxUpdateSignature
};
return LTI;
// JavaScript LTI XModule constructor
function LTIConstructor(element) {
var _this = this;
// In cms (Studio) the element is already a jQuery object. In lms it is
// a DOM object.
//
// To make sure that there is no error, we pass it through the $()
// function. This will make it a jQuery object if it isn't already so.
this.el = $(element);
this.formEl = this.el.find('.ltiLaunchForm');
this.formAction = this.formEl.attr('action');
// If action is empty string, or action is the default URL that should
// not cause a form submit.
if (!this.formAction || this.formAction === 'http://www.example.com') {
// Nothing to do - no valid action provided. Error message will be
// displaced in browser (HTML).
return;
}
this.ltiEl = this.el.find('.lti');
// We want a Boolean 'true' or 'false'. First we will retrieve the data
// attribute.
this.openInANewPage = this.ltiEl.data('open_in_a_new_page');
// Then we will parse it via native JSON.parse().
this.openInANewPage = JSON.parse(this.openInANewPage);
// The URL where we can request for a new OAuth signature for form
// submission to the LTI provider.
this.ajaxUrl = this.ltiEl.data('ajax_url');
// The OAuth signature can only be used once (because of timestamp
// and nonce). This will be reset each time the form is submitted so
// that we know to fetch a new OAuth signature on subsequent form
// submit.
this.signatureIsNew = true;
// If the Form's action attribute is set (i.e. we can perform a normal
// submit), then we (depending on instance settings) submit the form
// when user will click on a link, or submit the form immediately.
if (this.openInANewPage === true) {
// From the start, the button is enabled.
this.disableOpenNewWindowBtn = false;
this.newWindowBtnEl = this.el.find('.link_lti_new_window')
.on(
'click',
function () {
// Don't allow clicking repeatedly on this button
// if we are waiting for an AJAX response (with new
// OAuth signature).
if (_this.disableOpenNewWindowBtn === true) {
return;
}
return _this.submitFormHandler();
}
);
} else {
// At this stage the form exists on the page and has a valid
// action. We are safe to submit it, even if `openInANewPage` is
// set to some weird value.
this.submitFormHandler();
}
}
// The form submit handler. Before the form is submitted, we must check if
// the OAuth signature is new (valid). If it is not new, block form
// submission and request for a signature. After a new signature is
// fetched, the form will be submitted.
function submitFormHandler() {
if (this.signatureIsNew) {
// Continue with submitting the form.
this.formEl.submit();
// If the OAuth signature is new, mark it as old.
this.signatureIsNew = false;
// If we have an "Open LTI in a new window" button.
if (this.newWindowBtnEl) {
// Enable clicking on the button again.
this.disableOpenNewWindowBtn = false;
}
} else {
// The OAuth signature is old. Request for a new OAuth signature.
//
// Don't submit the form. It will be submitted once a new OAuth
// signature is received.
this.getNewSignature();
}
}
// Request form the server a new OAuth signature.
function getNewSignature() {
var _this = this;
// If we have an "Open LTI in a new window" button.
if (this.newWindowBtnEl) {
// Make sure that while we are waiting for a new signature, the
// user can't click on the "Open LTI in a new window" button
// repeatedly.
this.disableOpenNewWindowBtn = true;
}
$.postWithPrefix(
this.ajaxUrl + '/regenerate_signature',
{},
function (response) {
return _this.handleAjaxUpdateSignature(response);
}
);
}
// When a new OAuth signature is received, and if the data received back is
// OK, update the form, and submit it.
function handleAjaxUpdateSignature(response) {
var _this = this;
// If the response is valid, and contains expected data.
if ($.isPlainObject(response.input_fields)) {
// We received a new OAuth signature.
this.signatureIsNew = true;
// Update the form fields with new data, and new OAuth
// signature.
$.each(response.input_fields, function (name, value) {
var inputEl = _this.formEl.find("input[name='" + name + "']");
inputEl.val(value);
});
// Submit the form.
this.submitFormHandler();
} else {
console.log('[LTI info]: ' + response.error);
}
}
});
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
(function (requirejs, require, define) {
// In the case when the LTI constructor will be called before
// RequireJS finishes loading all of the LTI dependencies, we will have
// a mock function that will collect all the elements that must be
// initialized as LTI elements.
//
// Once RequireJS will load all of the necessary dependencies, main code
// will invoke the mock function with the second parameter set to truthy value.
// This will trigger the actual LTI constructor on all elements that
// are stored in a temporary list.
window.LTI = (function () {
// Temporary storage place for elements that must be initialized as LTI
// elements.
var tempCallStack = [];
return function (element, processTempCallStack) {
// If mock function was called with second parameter set to truthy
// value, we invoke the real `window.LTI` on all the stored elements
// so far.
if (processTempCallStack) {
$.each(tempCallStack, function (index, element) {
// By now, `window.LTI` is the real constructor.
window.LTI(element);
});
return;
}
// If normal call to `window.LTI` constructor, store the element
// for later initializing.
tempCallStack.push(element);
// Real LTI constructor returns `undefined`. The mock constructor will
// return the same value. Making this explicit.
return undefined;
};
}());
// Main module.
require(
[
'lti/01_lti.js'
],
function (
LTIConstructor
) {
var oldLTI = window.LTI;
window.LTI = LTIConstructor;
// Invoke the mock LTI constructor so that the elements stored within
// it can be processed by the real `window.LTI` constructor.
oldLTI(null, true);
});
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
......@@ -180,14 +180,7 @@ class LTIModule(LTIFields, XModule):
Otherwise error message from LTI provider is generated.
"""
js = {
'js': [
resource_string(__name__, 'js/src/lti/01_lti.js'),
resource_string(__name__, 'js/src/lti/02_main.js')
]
}
css = {'scss': [resource_string(__name__, 'css/lti/lti.scss')]}
js_module_name = "LTI"
def get_input_fields(self):
# LTI provides a list of default parameters that might be passed as
......@@ -253,12 +246,11 @@ class LTIModule(LTIFields, XModule):
client_secret,
)
def get_html(self):
def get_context(self):
"""
Renders parameters to template.
Returns a context.
"""
context = {
return {
'input_fields': self.get_input_fields(),
# These parameters do not participate in OAuth signing.
......@@ -267,12 +259,27 @@ class LTIModule(LTIFields, XModule):
'element_class': self.category,
'open_in_a_new_page': self.open_in_a_new_page,
'display_name': self.display_name,
'ajax_url': self.system.ajax_url,
'form_url': self.get_form_path(),
}
return self.system.render_template('lti.html', context)
def handle_ajax(self, dispatch, __):
def get_form_path(self):
return self.runtime.handler_url(self, 'preview_handler').rstrip('/?')
def get_html(self):
"""
Renders parameters to template.
"""
return self.system.render_template('lti.html', self.get_context())
def get_form(self):
"""
Renders parameters to form template.
"""
return self.system.render_template('lti_form.html', self.get_context())
@XBlock.handler
def preview_handler(self, request, dispatch):
"""
Ajax handler.
......@@ -282,10 +289,7 @@ class LTIModule(LTIFields, XModule):
Returns:
json string
"""
if dispatch == 'regenerate_signature':
return json.dumps({ 'input_fields': self.get_input_fields() })
else: # return error message
return json.dumps({ 'error': '[handle_ajax]: Unknown Command!' })
return Response(self.get_form(), content_type='text/html')
def get_user_id(self):
user_id = self.runtime.anonymous_student_id
......@@ -614,3 +618,4 @@ class LTIDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescri
"""
module_class = LTIModule
grade_handler = module_attr('grade_handler')
preview_handler = module_attr('preview_handler')
......@@ -229,6 +229,12 @@ class LTIModuleTest(LogicTest):
real_outcome_service_url = self.xmodule.get_outcome_service_url()
self.assertEqual(real_outcome_service_url, expected_outcome_service_url)
def test_get_form_path(self):
expected_form_path = self.xmodule.runtime.handler_url(self.xmodule, 'preview_handler').rstrip('/?')
real_form_path = self.xmodule.get_form_path()
self.assertEqual(real_form_path, expected_form_path)
def test_resource_link_id(self):
with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id:
mock_id.return_value = self.module_id
......@@ -251,28 +257,6 @@ class LTIModuleTest(LogicTest):
def test_client_key_secret(self):
pass
def test_handle_ajax(self):
dispatch = 'regenerate_signature'
data = ''
self.xmodule.get_input_fields = Mock(return_value={'test_input_field_key': 'test_input_field_value'})
json_dump = self.xmodule.handle_ajax(dispatch, data)
expected_json_dump = '{"input_fields": {"test_input_field_key": "test_input_field_value"}}'
self.assertEqual(
json.loads(json_dump),
json.loads(expected_json_dump)
)
def test_handle_ajax_bad_dispatch(self):
dispatch = 'bad_dispatch'
data = ''
self.xmodule.get_input_fields = Mock(return_value={'test_input_field_key': 'test_input_field_value'})
json_dump = self.xmodule.handle_ajax(dispatch, data)
expected_json_dump = '{"error": "[handle_ajax]: Unknown Command!"}'
self.assertEqual(
json.loads(json_dump),
json.loads(expected_json_dump)
)
def test_max_score(self):
self.xmodule.weight = 100.0
......
......@@ -13,22 +13,22 @@ from courseware.tests.factories import InstructorFactory
@step('I view the LTI and error is shown$')
def lti_is_not_rendered(_step):
# error is shown
assert world.is_css_present('.error_message')
assert world.is_css_present('.error_message', wait_time=0)
# iframe is not presented
assert not world.is_css_present('iframe')
assert not world.is_css_present('iframe', wait_time=0)
# link is not presented
assert not world.is_css_present('.link_lti_new_window')
assert not world.is_css_present('.link_lti_new_window', wait_time=0)
def check_lti_iframe_content(text):
#inside iframe test content is presented
location = world.scenario_dict['LTI'].location.html_id()
iframe_name = 'ltiLaunchFrame-' + location
iframe_name = 'ltiFrame-' + location
with world.browser.get_iframe(iframe_name) as iframe:
# iframe does not contain functions from terrain/ui_helpers.py
assert iframe.is_element_present_by_css('.result', wait_time=5)
assert iframe.is_element_present_by_css('.result', wait_time=0)
assert (text == world.retry_on_exception(
lambda: iframe.find_by_css('.result')[0].text,
max_attempts=5
......@@ -38,18 +38,18 @@ def check_lti_iframe_content(text):
@step('I view the LTI and it is rendered in (.*)$')
def lti_is_rendered(_step, rendered_in):
if rendered_in.strip() == 'iframe':
assert world.is_css_present('iframe')
assert not world.is_css_present('.link_lti_new_window')
assert not world.is_css_present('.error_message')
assert world.is_css_present('iframe', wait_time=2)
assert not world.is_css_present('.link_lti_new_window', wait_time=0)
assert not world.is_css_present('.error_message', wait_time=0)
# iframe is visible
assert world.css_visible('iframe')
check_lti_iframe_content("This is LTI tool. Success.")
elif rendered_in.strip() == 'new page':
assert not world.is_css_present('iframe')
assert world.is_css_present('.link_lti_new_window')
assert not world.is_css_present('.error_message')
assert not world.is_css_present('iframe', wait_time=2)
assert world.is_css_present('.link_lti_new_window', wait_time=0)
assert not world.is_css_present('.error_message', wait_time=0)
check_lti_popup()
else: # incorrent rendered_in parameter
assert False
......@@ -57,9 +57,9 @@ def lti_is_rendered(_step, rendered_in):
@step('I view the LTI but incorrect_signature warning is rendered$')
def incorrect_lti_is_rendered(_step):
assert world.is_css_present('iframe')
assert not world.is_css_present('.link_lti_new_window')
assert not world.is_css_present('.error_message')
assert world.is_css_present('iframe', wait_time=2)
assert not world.is_css_present('.link_lti_new_window', wait_time=0)
assert not world.is_css_present('.error_message', wait_time=0)
#inside iframe test content is presented
check_lti_iframe_content("Wrong LTI signature")
......@@ -234,10 +234,11 @@ def check_progress(_step, text):
@step('I see graph with total progress "([^"]*)"$')
def see_graph(_step, progress):
SELECTOR = 'grade-detail-graph'
node = world.browser.find_by_xpath('//div[@id="{parent}"]//div[text()="{progress}"]'.format(
XPATH = '//div[@id="{parent}"]//div[text()="{progress}"]'.format(
parent=SELECTOR,
progress=progress,
))
)
node = world.browser.find_by_xpath(XPATH)
assert node
......@@ -259,7 +260,7 @@ def see_value_in_the_gradebook(_step, label, text):
@step('I submit answer to LTI question$')
def click_grade(_step):
location = world.scenario_dict['LTI'].location.html_id()
iframe_name = 'ltiLaunchFrame-' + location
iframe_name = 'ltiFrame-' + location
with world.browser.get_iframe(iframe_name) as iframe:
iframe.find_by_name('submit-button').first.click()
assert iframe.is_text_present('LTI consumer (edX) responded with XML content')
......
......@@ -5,8 +5,6 @@ from . import BaseTestXmodule
from collections import OrderedDict
import mock
import urllib
from xmodule.lti_module import LTIModule
from mock import Mock
class TestLTI(BaseTestXmodule):
......@@ -85,7 +83,6 @@ class TestLTI(BaseTestXmodule):
Makes sure that all parameters extracted.
"""
generated_context = self.item_module.render('student_view').content
expected_context = {
'display_name': self.item_module.display_name,
'input_fields': self.correct_headers,
......@@ -93,7 +90,7 @@ class TestLTI(BaseTestXmodule):
'element_id': self.item_module.location.html_id(),
'launch_url': 'http://www.example.com', # default value
'open_in_a_new_page': True,
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url,
'form_url': self.item_descriptor.xmodule_runtime.handler_url(self.item_module, 'preview_handler').rstrip('/?'),
}
self.assertEqual(
......
......@@ -4,40 +4,15 @@
<div
id="${element_id}"
class="${element_class}"
data-open_in_a_new_page="${json.dumps(open_in_a_new_page)}"
data-ajax_url="${ajax_url}"
>
## This form will be hidden.
## If open_in_a_new_page is false then, once available on the client, the
## LTI module JavaScript will trigger a "submit" on the form, and the
## result will be rendered to the below iFrame.
## If open_in_a_new_page is true, then link will be shown, and by clicking
## on it, LTI will pop up in new window.
<form
action="${launch_url}"
name="ltiLaunchForm-${element_id}"
class="ltiLaunchForm"
method="post"
target=${"_blank" if open_in_a_new_page else "ltiLaunchFrame-{0}".format(element_id)}
encType="application/x-www-form-urlencoded"
>
% for param_name, param_value in input_fields.items():
<input name="${param_name}" value="${param_value}" />
%endfor
<input type="submit" value="Press to Launch" />
</form>
% if launch_url and launch_url != 'http://www.example.com':
% if open_in_a_new_page:
<div class="wrapper-lti-link">
<h3 class="title">
${display_name} (${_('External resource')})
</h3>
<p class="lti-link external"><a href="#" class='link_lti_new_window'>
<p class="lti-link external"><a target="_blank" class="link_lti_new_window" href="${form_url}">
${_('View resource in a new window')}
<i class="icon-external-link"></i>
</a></p>
......@@ -45,9 +20,9 @@
% else:
## The result of the form submit will be rendered here.
<iframe
name="ltiLaunchFrame-${element_id}"
class="ltiLaunchFrame"
src=""
name="ltiFrame-${element_id}"
src="${form_url}"
></iframe>
% endif
% else:
......
<%! import json %>
<%! from django.utils.translation import ugettext as _ %>
<!DOCTYPE HTML>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>LTI</title>
</head>
<body>
## This form will be hidden.
## LTI module JavaScript will trigger a "submit" on the form, and the
## result will be rendered instead.
<form
id="lti-${element_id}"
action="${launch_url}"
method="post"
encType="application/x-www-form-urlencoded"
style="display:none;"
>
% for param_name, param_value in input_fields.items():
<input name="${param_name}" value="${param_value}" />
%endfor
<input type="submit" value="Press to Launch" />
</form>
<script type="text/javascript">
(function (d) {
var element = d.getElementById("lti-${element_id}");
if (element) {
element.submit();
}
}(document));
</script>
</body>
</html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment