Commit df262d4f by Christina Roberts

Merge pull request #3229 from edx/christina/container-dnd

Drag and drop on container page
parents 56924af9 7693659e
......@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
Studio: Add drag-and-drop support to the container page. STUD-1309.
Common: Add extensible third-party auth module.
Blades: Handle situation if no response were sent from XQueue to LMS in Matlab
......
......@@ -35,6 +35,7 @@ from ..utils import get_modulestore
from .access import has_course_access
from .helpers import _xmodule_recurse
from contentstore.utils import compute_publish_state, PublishState
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
from contentstore.views.preview import get_preview_fragment
from edxmako.shortcuts import render_to_string
from models.settings.course_grading import CourseGradingModel
......@@ -193,6 +194,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
if 'application/json' in accept_header:
store = get_modulestore(old_location)
component = store.get_item(old_location)
is_read_only = _xblock_is_read_only(component)
# wrap the generated fragment in the xmodule_editor div so that the javascript
# can bind to it correctly
......@@ -212,12 +214,18 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
store.update_item(component, None)
elif view_name == 'student_view' and component.has_children:
context = {
'runtime_type': 'studio',
'container_view': False,
'read_only': is_read_only,
'root_xblock': component,
}
# For non-leaf xblocks on the unit page, show the special rendering
# which links to the new container page.
html = render_to_string('container_xblock_component.html', {
'xblock_context': context,
'xblock': component,
'locator': locator,
'reordering_enabled': True,
})
return JsonResponse({
'html': html,
......@@ -225,8 +233,6 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
})
elif view_name in ('student_view', 'container_preview'):
is_container_view = (view_name == 'container_preview')
component_publish_state = compute_publish_state(component)
is_read_only_view = component_publish_state == PublishState.public
# Only show the new style HTML for the container view, i.e. for non-verticals
# Note: this special case logic can be removed once the unit page is replaced
......@@ -234,7 +240,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
context = {
'runtime_type': 'studio',
'container_view': is_container_view,
'read_only': is_read_only_view,
'read_only': is_read_only,
'root_xblock': component,
}
......@@ -244,6 +250,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
# into the preview fragment, so we don't want to add another header here.
if not is_container_view:
fragment.content = render_to_string('component.html', {
'xblock_context': context,
'preview': fragment.content,
'label': component.display_name or component.scope_ids.block_type,
})
......@@ -263,6 +270,17 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
return HttpResponse(status=406)
def _xblock_is_read_only(xblock):
"""
Returns true if the specified xblock is read-only, meaning that it cannot be edited.
"""
# We allow direct editing of xblocks in DIRECT_ONLY_CATEGORIES (for example, static pages).
if xblock.category in DIRECT_ONLY_CATEGORIES:
return False
component_publish_state = compute_publish_state(xblock)
return component_publish_state == PublishState.public
def _save_item(request, usage_loc, item_location, data=None, children=None, metadata=None, nullout=None,
grader_type=None, publish=None):
"""
......
......@@ -2,10 +2,12 @@
Unit tests for the container view.
"""
import json
from contentstore.tests.utils import CourseTestCase
from contentstore.utils import compute_publish_state, PublishState
from contentstore.views.helpers import xblock_studio_url
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.django import loc_mapper, modulestore
from xmodule.modulestore.tests.factories import ItemFactory
......@@ -56,7 +58,6 @@ class ContainerViewTestCase(CourseTestCase):
parent_location=published_xblock_with_child.location,
category="html", display_name="Child HTML"
)
draft_xblock_with_child = modulestore('draft').convert_to_draft(published_xblock_with_child.location)
branch_name = "MITx.999.Robot_Super_Course/branch/draft/block"
self._test_html_content(
published_xblock_with_child,
......@@ -73,6 +74,11 @@ class ContainerViewTestCase(CourseTestCase):
r'<a href="#" class="navigation-link navigation-current">Wrapper</a>'
).format(branch_name=branch_name)
)
# Now make the unit and its children into a draft and validate the container again
modulestore('draft').convert_to_draft(self.vertical.location)
modulestore('draft').convert_to_draft(self.child_vertical.location)
draft_xblock_with_child = modulestore('draft').convert_to_draft(published_xblock_with_child.location)
self._test_html_content(
draft_xblock_with_child,
branch_name=branch_name,
......@@ -112,3 +118,37 @@ class ContainerViewTestCase(CourseTestCase):
branch_name=branch_name
)
self.assertIn(expected_unit_link, html)
def test_container_preview_html(self):
"""
Verify that an xblock returns the expected HTML for a container preview
"""
# First verify that the behavior is correct with a published container
self._test_preview_html(self.vertical)
self._test_preview_html(self.child_vertical)
# Now make the unit and its children into a draft and validate the preview again
draft_unit = modulestore('draft').convert_to_draft(self.vertical.location)
draft_container = modulestore('draft').convert_to_draft(self.child_vertical.location)
self._test_preview_html(draft_unit)
self._test_preview_html(draft_container)
def _test_preview_html(self, xblock):
"""
Verify that the specified xblock has the expected HTML elements for container preview
"""
locator = loc_mapper().translate_location(self.course.id, xblock.location, published=False)
publish_state = compute_publish_state(xblock)
preview_url = '/xblock/{locator}/container_preview'.format(locator=locator)
resp = self.client.get(preview_url, HTTP_ACCEPT='application/json')
self.assertEqual(resp.status_code, 200)
resp_content = json.loads(resp.content)
html = resp_content['html']
# Verify that there are no drag handles for public pages
drag_handle_html = '<span data-tooltip="Drag to reorder" class="drag-handle action"></span>'
if publish_state == PublishState.public:
self.assertNotIn(drag_handle_html, html)
else:
self.assertIn(drag_handle_html, html)
......@@ -4,6 +4,7 @@ import json
from contentstore.views import tabs
from contentstore.tests.utils import CourseTestCase
from django.test import TestCase
from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from courseware.courses import get_course_by_id
from xmodule.tabs import CourseTabList, WikiTab
......@@ -22,7 +23,7 @@ class TabsPageTests(CourseTestCase):
self.url = self.course_locator.url_reverse('tabs')
# add a static tab to the course, for code coverage
ItemFactory.create(
self.test_tab = ItemFactory.create(
parent_location=self.course_location,
category="static_tab",
display_name="Static_1"
......@@ -172,6 +173,25 @@ class TabsPageTests(CourseTestCase):
)
self.check_invalid_tab_id_response(resp)
def test_tab_preview_html(self):
"""
Verify that the static tab renders itself with the correct HTML
"""
locator = loc_mapper().translate_location(self.course.id, self.test_tab.location)
preview_url = '/xblock/{locator}/student_view'.format(locator=locator)
resp = self.client.get(preview_url, HTTP_ACCEPT='application/json')
self.assertEqual(resp.status_code, 200)
resp_content = json.loads(resp.content)
html = resp_content['html']
# Verify that the HTML contains the expected elements
self.assertIn('<span class="action-button-text">Edit</span>', html)
self.assertIn('<span class="sr">Duplicate this component</span>', html)
self.assertIn('<span class="sr">Delete this component</span>', html)
self.assertIn('<span data-tooltip="Drag to reorder" class="drag-handle"></span>', html)
class PrimitiveTabEdit(TestCase):
"""Tests for the primitive tab edit data manipulations"""
......
......@@ -18,6 +18,7 @@ requirejs.config({
"jquery.iframe-transport": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport",
"jquery.inputnumber": "xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill",
"jquery.immediateDescendents": "xmodule_js/common_static/coffee/src/jquery.immediateDescendents",
"jquery.simulate": "xmodule_js/common_static/js/vendor/jquery.simulate",
"datepair": "xmodule_js/common_static/js/vendor/timepicker/datepair",
"date": "xmodule_js/common_static/js/vendor/date",
"underscore": "xmodule_js/common_static/js/vendor/underscore-min",
......@@ -100,6 +101,10 @@ requirejs.config({
deps: ["jquery"],
exports: "jQuery.fn.inputNumber"
},
"jquery.simulate": {
deps: ["jquery"],
exports: "jQuery.fn.simulate"
},
"jquery.tinymce": {
deps: ["jquery", "tinymce"],
exports: "jQuery.fn.tinymce"
......@@ -216,6 +221,7 @@ define([
"js/spec/views/baseview_spec",
"js/spec/views/paging_spec",
"js/spec/views/container_spec",
"js/spec/views/unit_spec",
"js/spec/views/xblock_spec",
"js/spec/views/xblock_editor_spec",
......
......@@ -12,7 +12,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
beforeEach(function () {
edit_helpers.installEditTemplates();
appendSetFixtures('<div class="xblock" data-locator="mock-xblock" data-display-name="Mock XBlock"></div>');
appendSetFixtures('<div class="xblock" data-locator="mock-xblock"></div>');
model = new XBlockInfo({
id: 'testCourse/branch/draft/block/verticalFFF',
display_name: 'Test Unit',
......
/**
* Provides helper methods for invoking Studio modal windows in Jasmine tests.
*/
define(["jquery"],
function($) {
define(["jquery", "js/spec_helpers/view_helpers"],
function($, view_helpers) {
var basicModalTemplate = readFixtures('basic-modal.underscore'),
modalButtonTemplate = readFixtures('modal-button.underscore'),
feedbackTemplate = readFixtures('system-feedback.underscore'),
......@@ -14,11 +14,7 @@ define(["jquery"],
cancelModalIfShowing;
installModalTemplates = function(append) {
if (append) {
appendSetFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate));
} else {
setFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate));
}
view_helpers.installViewTemplates(append);
appendSetFixtures($("<script>", { id: "basic-modal-tpl", type: "text/template" }).text(basicModalTemplate));
appendSetFixtures($("<script>", { id: "modal-button-tpl", type: "text/template" }).text(modalButtonTemplate));
};
......@@ -58,11 +54,11 @@ define(["jquery"],
}
};
return {
return $.extend(view_helpers, {
'installModalTemplates': installModalTemplates,
'isShowingModal': isShowingModal,
'hideModalIfShowing': hideModalIfShowing,
'cancelModal': cancelModal,
'cancelModalIfShowing': cancelModalIfShowing
};
});
});
/**
* Provides helper methods for invoking Studio modal windows in Jasmine tests.
*/
define(["jquery"],
function($) {
var feedbackTemplate = readFixtures('system-feedback.underscore'),
installViewTemplates;
installViewTemplates = function(append) {
if (append) {
appendSetFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate));
} else {
setFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate));
}
};
return {
'installViewTemplates': installViewTemplates
};
});
define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification"],
function ($, _, XBlockView, ModuleUtils, gettext, NotificationView) {
var ContainerView = XBlockView.extend({
xblockReady: function () {
XBlockView.prototype.xblockReady.call(this);
var verticalContainer = this.$('.vertical-container'),
alreadySortable = this.$('.ui-sortable'),
newParent,
oldParent,
self = this;
alreadySortable.sortable("destroy");
verticalContainer.sortable({
handle: '.drag-handle',
stop: function (event, ui) {
var saving, hideSaving, removeFromParent;
if (oldParent === undefined) {
// If no actual change occurred,
// oldParent will never have been set.
return;
}
saving = new NotificationView.Mini({
title: gettext('Saving&hellip;')
});
saving.show();
hideSaving = function () {
saving.hide();
};
// If moving from one container to another,
// add to new container before deleting from old to
// avoid creating an orphan if the addition fails.
if (newParent) {
removeFromParent = oldParent;
self.reorder(newParent, function () {
self.reorder(removeFromParent, hideSaving);
});
} else {
// No new parent, only reordering within same container.
self.reorder(oldParent, hideSaving);
}
oldParent = undefined;
newParent = undefined;
},
update: function (event, ui) {
// When dragging from one ol to another, this method
// will be called twice (once for each list). ui.sender will
// be null if the change is related to the list the element
// was originally in (the case of a move within the same container
// or the deletion from a container when moving to a new container).
var parent = $(event.target).closest('.wrapper-xblock');
if (ui.sender) {
// Move to a new container (the addition part).
newParent = parent;
} else {
// Reorder inside a container, or deletion when moving to new container.
oldParent = parent;
}
},
helper: "original",
opacity: '0.5',
placeholder: 'component-placeholder',
forcePlaceholderSize: true,
axis: 'y',
items: '> .vertical-element',
connectWith: ".vertical-container",
tolerance: "pointer"
});
},
reorder: function (targetParent, successCallback) {
var children, childLocators;
// Find descendants with class "wrapper-xblock" whose parent == targetParent.
// This is necessary to filter our grandchildren, great-grandchildren, etc.
children = targetParent.find('.wrapper-xblock').filter(function () {
var parent = $(this).parent().closest('.wrapper-xblock');
return parent.data('locator') === targetParent.data('locator');
});
childLocators = _.map(
children,
function (child) {
return $(child).data('locator');
}
);
$.ajax({
url: ModuleUtils.getUpdateUrl(targetParent.data('locator')),
type: 'PUT',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify({
children: childLocators
}),
success: function () {
// change data-parent on the element moved.
if (successCallback) {
successCallback();
}
}
});
}
});
return ContainerView;
}); // end define();
......@@ -2,8 +2,8 @@
* XBlockContainerView is used to display an xblock which has children, and allows the
* user to interact with the children.
*/
define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt", "js/views/baseview", "js/views/xblock", "js/views/modals/edit_xblock", "js/models/xblock_info"],
function ($, _, gettext, NotificationView, PromptView, BaseView, XBlockView, EditXBlockModal, XBlockInfo) {
define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt", "js/views/baseview", "js/views/container", "js/views/xblock", "js/views/modals/edit_xblock", "js/models/xblock_info"],
function ($, _, gettext, NotificationView, PromptView, BaseView, ContainerView, XBlockView, EditXBlockModal, XBlockInfo) {
var XBlockContainerView = BaseView.extend({
// takes XBlockInfo as a model
......@@ -13,7 +13,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
initialize: function() {
BaseView.prototype.initialize.call(this);
this.noContentElement = this.$('.no-container-content');
this.xblockView = new XBlockView({
this.xblockView = new ContainerView({
el: this.$('.wrapper-xblock'),
model: this.model,
view: this.view
......@@ -184,4 +184,3 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
return XBlockContainerView;
}); // end define();
......@@ -34,6 +34,7 @@ lib_paths:
- xmodule_js/common_static/js/vendor/jquery.min.js
- xmodule_js/common_static/js/vendor/jquery-ui.min.js
- xmodule_js/common_static/js/vendor/jquery.cookie.js
- xmodule_js/common_static/js/vendor/jquery.simulate.js
- xmodule_js/common_static/js/vendor/underscore-min.js
- xmodule_js/common_static/js/vendor/underscore.string.min.js
- xmodule_js/common_static/js/vendor/backbone-min.js
......
......@@ -4,7 +4,7 @@
// basic setup
html {
font-size: 62.5%;
overflow-y: scroll;
height: 102%; // force scrollbar to prevent jump when scroll appears, cannot use overflow because it breaks drag
}
body {
......
......@@ -227,11 +227,12 @@
.action-item {
display: inline-block;
vertical-align: middle;
.action-button {
display: block;
border-radius: 3px;
padding: ($baseline/4) ($baseline/2);
height: ($baseline*1.5);
color: $gray-l1;
&:hover {
......@@ -248,6 +249,15 @@
background-color: $gray-l1;
}
}
.drag-handle {
display: block;
float: none;
height: ($baseline*1.2);
width: ($baseline);
margin: 0;
background: transparent url("../img/drag-handles.png") no-repeat right center;
}
}
}
......
......@@ -19,6 +19,7 @@
@include box-sizing(border-box);
@include ui-flexbox();
@extend %ui-align-center-flex;
justify-content: space-between;
border-bottom: 1px solid $gray-l4;
border-radius: ($baseline/5) ($baseline/5) 0 0;
min-height: ($baseline*2.5);
......@@ -30,14 +31,14 @@
@extend %ui-justify-left-flex;
@include ui-flexbox();
width: flex-grid(6,12);
vertical-align: top;
vertical-align: middle;
}
.header-actions {
@include ui-flexbox();
@extend %ui-justify-right-flex;
width: flex-grid(6,12);
vertical-align: top;
vertical-align: middle;
}
}
}
......
......@@ -7,7 +7,7 @@
// ====================
// UI: container page view
body.view-container {
.view-container {
.mast {
border-bottom: none;
......@@ -97,7 +97,58 @@ body.view-container {
}
// UI: xblock rendering
body.view-container .content-primary {
body.view-container .content-primary {
// dragging bits
.ui-sortable-helper {
article {
display: none;
}
}
.component-placeholder {
height: ($baseline*2.5);
opacity: .5;
margin: $baseline;
background-color: $gray-l5;
border-radius: ($baseline/2);
border: 2px dashed $gray-l2;
}
.vert-mod {
// min-height to allow drop when empty
.vertical-container {
min-height: ($baseline*2.5);
}
.vert {
position: relative;
.drag-handle {
display: none; // only show when vert is draggable
position: absolute;
top: 0;
right: ($baseline/2); // equal to margin on component
width: ($baseline*1.5);
height: ($baseline*2.5);
margin: 0;
background: transparent url("../img/drag-handles.png") no-repeat scroll center center;
}
}
.is-draggable {
.xblock-header {
padding-right: ($baseline*1.5); // make room for drag handle
}
.drag-handle {
display: block;
}
}
}
.wrapper-xblock {
@extend %wrap-xblock;
......
......@@ -26,6 +26,7 @@
</li>
</ul>
</div>
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
% if not xblock_context['read_only']:
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
% endif
${preview}
......@@ -21,8 +21,7 @@ from contentstore.views.helpers import xblock_studio_url
</ul>
</div>
</header>
## We currently support reordering only on the unit page.
% if reordering_enabled:
% if not xblock_context['read_only']:
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
% endif
</section>
......@@ -154,7 +154,7 @@ class DraftModuleStore(MongoModuleStore):
self.refresh_cached_metadata_inheritance_tree(draft_location)
self.fire_updated_modulestore_signal(get_course_id_no_run(draft_location), draft_location)
return self._load_items([original])[0]
return wrap_draft(self._load_items([original])[0])
def update_item(self, xblock, user=None, allow_not_found=False):
"""
......
......@@ -18,6 +18,22 @@ class VerticalModule(VerticalFields, XModule):
''' Layout module for laying out submodules vertically.'''
def student_view(self, context):
# When rendering a Studio preview, use a different template to support drag and drop.
if context and context.get('runtime_type', None) == 'studio':
return self.studio_preview_view(context)
return self.render_view(context, 'vert_module.html')
def studio_preview_view(self, context):
"""
Renders the Studio preview view, which supports drag and drop.
"""
return self.render_view(context, 'vert_module_studio_view.html')
def render_view(self, context, template_name):
"""
Helper method for rendering student_view and the Studio version.
"""
fragment = Fragment()
contents = []
......@@ -33,8 +49,9 @@ class VerticalModule(VerticalFields, XModule):
'content': rendered_child.content
})
fragment.add_content(self.system.render_template('vert_module.html', {
'items': contents
fragment.add_content(self.system.render_template(template_name, {
'items': contents,
'xblock_context': context,
}))
return fragment
......
/*!
* jQuery Simulate v@VERSION - simulate browser mouse and keyboard events
* https://github.com/jquery/jquery-simulate
*
* Copyright 2012 jQuery Foundation and other contributors
* Released under the MIT license.
* http://jquery.org/license
*
* Date: @DATE
*/
;(function( $, undefined ) {
var rkeyEvent = /^key/,
rmouseEvent = /^(?:mouse|contextmenu)|click/;
$.fn.simulate = function( type, options ) {
return this.each(function() {
new $.simulate( this, type, options );
});
};
$.simulate = function( elem, type, options ) {
var method = $.camelCase( "simulate-" + type );
this.target = elem;
this.options = options;
if ( this[ method ] ) {
this[ method ]();
} else {
this.simulateEvent( elem, type, options );
}
};
$.extend( $.simulate, {
keyCode: {
BACKSPACE: 8,
COMMA: 188,
DELETE: 46,
DOWN: 40,
END: 35,
ENTER: 13,
ESCAPE: 27,
HOME: 36,
LEFT: 37,
NUMPAD_ADD: 107,
NUMPAD_DECIMAL: 110,
NUMPAD_DIVIDE: 111,
NUMPAD_ENTER: 108,
NUMPAD_MULTIPLY: 106,
NUMPAD_SUBTRACT: 109,
PAGE_DOWN: 34,
PAGE_UP: 33,
PERIOD: 190,
RIGHT: 39,
SPACE: 32,
TAB: 9,
UP: 38
},
buttonCode: {
LEFT: 0,
MIDDLE: 1,
RIGHT: 2
}
});
$.extend( $.simulate.prototype, {
simulateEvent: function( elem, type, options ) {
var event = this.createEvent( type, options );
this.dispatchEvent( elem, type, event, options );
},
createEvent: function( type, options ) {
if ( rkeyEvent.test( type ) ) {
return this.keyEvent( type, options );
}
if ( rmouseEvent.test( type ) ) {
return this.mouseEvent( type, options );
}
},
mouseEvent: function( type, options ) {
var event, eventDoc, doc, body;
options = $.extend({
bubbles: true,
cancelable: (type !== "mousemove"),
view: window,
detail: 0,
screenX: 0,
screenY: 0,
clientX: 1,
clientY: 1,
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
button: 0,
relatedTarget: undefined
}, options );
if ( document.createEvent ) {
event = document.createEvent( "MouseEvents" );
event.initMouseEvent( type, options.bubbles, options.cancelable,
options.view, options.detail,
options.screenX, options.screenY, options.clientX, options.clientY,
options.ctrlKey, options.altKey, options.shiftKey, options.metaKey,
options.button, options.relatedTarget || document.body.parentNode );
// IE 9+ creates events with pageX and pageY set to 0.
// Trying to modify the properties throws an error,
// so we define getters to return the correct values.
if ( event.pageX === 0 && event.pageY === 0 && Object.defineProperty ) {
eventDoc = event.relatedTarget.ownerDocument || document;
doc = eventDoc.documentElement;
body = eventDoc.body;
Object.defineProperty( event, "pageX", {
get: function() {
return options.clientX +
( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) -
( doc && doc.clientLeft || body && body.clientLeft || 0 );
}
});
Object.defineProperty( event, "pageY", {
get: function() {
return options.clientY +
( doc && doc.scrollTop || body && body.scrollTop || 0 ) -
( doc && doc.clientTop || body && body.clientTop || 0 );
}
});
}
} else if ( document.createEventObject ) {
event = document.createEventObject();
$.extend( event, options );
// standards event.button uses constants defined here: http://msdn.microsoft.com/en-us/library/ie/ff974877(v=vs.85).aspx
// old IE event.button uses constants defined here: http://msdn.microsoft.com/en-us/library/ie/ms533544(v=vs.85).aspx
// so we actually need to map the standard back to oldIE
event.button = {
0: 1,
1: 4,
2: 2
}[ event.button ] || event.button;
}
return event;
},
keyEvent: function( type, options ) {
var event;
options = $.extend({
bubbles: true,
cancelable: true,
view: window,
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
keyCode: 0,
charCode: undefined
}, options );
if ( document.createEvent ) {
try {
event = document.createEvent( "KeyEvents" );
event.initKeyEvent( type, options.bubbles, options.cancelable, options.view,
options.ctrlKey, options.altKey, options.shiftKey, options.metaKey,
options.keyCode, options.charCode );
// initKeyEvent throws an exception in WebKit
// see: http://stackoverflow.com/questions/6406784/initkeyevent-keypress-only-works-in-firefox-need-a-cross-browser-solution
// and also https://bugs.webkit.org/show_bug.cgi?id=13368
// fall back to a generic event until we decide to implement initKeyboardEvent
} catch( err ) {
event = document.createEvent( "Events" );
event.initEvent( type, options.bubbles, options.cancelable );
$.extend( event, {
view: options.view,
ctrlKey: options.ctrlKey,
altKey: options.altKey,
shiftKey: options.shiftKey,
metaKey: options.metaKey,
keyCode: options.keyCode,
charCode: options.charCode
});
}
} else if ( document.createEventObject ) {
event = document.createEventObject();
$.extend( event, options );
}
if ( !!/msie [\w.]+/.exec( navigator.userAgent.toLowerCase() ) || (({}).toString.call( window.opera ) === "[object Opera]") ) {
event.keyCode = (options.charCode > 0) ? options.charCode : options.keyCode;
event.charCode = undefined;
}
return event;
},
dispatchEvent: function( elem, type, event ) {
if ( elem.dispatchEvent ) {
elem.dispatchEvent( event );
} else if ( elem.fireEvent ) {
elem.fireEvent( "on" + type, event );
}
},
simulateFocus: function() {
var focusinEvent,
triggered = false,
element = $( this.target );
function trigger() {
triggered = true;
}
element.bind( "focus", trigger );
element[ 0 ].focus();
if ( !triggered ) {
focusinEvent = $.Event( "focusin" );
focusinEvent.preventDefault();
element.trigger( focusinEvent );
element.triggerHandler( "focus" );
}
element.unbind( "focus", trigger );
},
simulateBlur: function() {
var focusoutEvent,
triggered = false,
element = $( this.target );
function trigger() {
triggered = true;
}
element.bind( "blur", trigger );
element[ 0 ].blur();
// blur events are async in IE
setTimeout(function() {
// IE won't let the blur occur if the window is inactive
if ( element[ 0 ].ownerDocument.activeElement === element[ 0 ] ) {
element[ 0 ].ownerDocument.body.focus();
}
// Firefox won't trigger events if the window is inactive
// IE doesn't trigger events if we had to manually focus the body
if ( !triggered ) {
focusoutEvent = $.Event( "focusout" );
focusoutEvent.preventDefault();
element.trigger( focusoutEvent );
element.triggerHandler( "blur" );
}
element.unbind( "blur", trigger );
}, 1 );
}
});
/** complex events **/
function findCenter( elem ) {
var offset,
document = $( elem.ownerDocument );
elem = $( elem );
offset = elem.offset();
return {
x: offset.left + elem.outerWidth() / 2 - document.scrollLeft(),
y: offset.top + elem.outerHeight() / 2 - document.scrollTop()
};
}
$.extend( $.simulate.prototype, {
simulateDrag: function() {
var i = 0,
target = this.target,
options = this.options,
center = findCenter( target ),
x = Math.floor( center.x ),
y = Math.floor( center.y ),
dx = options.dx || 0,
dy = options.dy || 0,
moves = options.moves || 3,
coord = { clientX: x, clientY: y };
this.simulateEvent( target, "mousedown", coord );
for ( ; i < moves ; i++ ) {
x += dx / moves;
y += dy / moves;
coord = {
clientX: Math.round( x ),
clientY: Math.round( y )
};
this.simulateEvent( document, "mousemove", coord );
}
this.simulateEvent( target, "mouseup", coord );
this.simulateEvent( target, "click", coord );
}
});
})( jQuery );
\ No newline at end of file
......@@ -6,6 +6,7 @@ from bok_choy.page_object import PageObject
from bok_choy.promise import Promise
from . import BASE_URL
from selenium.webdriver.common.action_chains import ActionChains
class ContainerPage(PageObject):
"""
......@@ -44,6 +45,24 @@ class ContainerPage(PageObject):
return self.q(css=XBlockWrapper.BODY_SELECTOR).map(
lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results
def drag(self, source_index, target_index, after=True):
"""
Gets the drag handle with index source_index (relative to the vertical layout of the page)
and drags it to the location of the drag handle with target_index.
This should drag the element with the source_index drag handle AFTER the
one with the target_index drag handle, unless 'after' is set to False.
"""
draggables = self.q(css='.drag-handle')
source = draggables[source_index]
target = draggables[target_index]
action = ActionChains(self.browser)
action.click_and_hold(source).perform() # pylint: disable=protected-access
action.move_to_element_with_offset(
target, 0, target.size['height'] / 2 if after else 0
).perform() # pylint: disable=protected-access
action.release().perform()
class XBlockWrapper(PageObject):
"""
......@@ -79,5 +98,21 @@ class XBlockWrapper(PageObject):
return None
@property
def children(self):
"""
Will return any first-generation descendant xblocks of this xblock.
"""
descendants = self.q(css=self._bounded_selector(self.BODY_SELECTOR)).map(
lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results
# Now remove any non-direct descendants.
grandkids = []
for descendant in descendants:
grandkids.extend(descendant.children)
grand_locators = [grandkid.locator for grandkid in grandkids]
return [descendant for descendant in descendants if not descendant.locator in grand_locators]
@property
def preview_selector(self):
return self._bounded_selector('.xblock-student_view')
"""
Acceptance tests for Studio related to the acid xblock.
"""
from unittest import skip
from bok_choy.web_app_test import WebAppTest
from ..pages.studio.auto_auth import AutoAuthPage
from ..pages.studio.overview import CourseOutlinePage
from ..pages.xblock.acid import AcidView
from ..fixtures.course import CourseFixture, XBlockFixtureDesc
class XBlockAcidBase(WebAppTest):
"""
Base class for tests that verify that XBlock integration is working correctly
"""
__test__ = False
def setUp(self):
"""
Create a unique identifier for the course used in this test.
"""
# Ensure that the superclass sets up
super(XBlockAcidBase, self).setUp()
# Define a unique course identifier
self.course_info = {
'org': 'test_org',
'number': 'course_' + self.unique_id[:5],
'run': 'test_' + self.unique_id,
'display_name': 'Test Course ' + self.unique_id
}
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.outline = CourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
self.course_id = '{org}.{number}.{run}'.format(**self.course_info)
self.setup_fixtures()
self.auth_page.visit()
def validate_acid_block_preview(self, acid_block):
"""
Validate the Acid Block's preview
"""
self.assertTrue(acid_block.init_fn_passed)
self.assertTrue(acid_block.resource_url_passed)
self.assertTrue(acid_block.scope_passed('user_state'))
self.assertTrue(acid_block.scope_passed('user_state_summary'))
self.assertTrue(acid_block.scope_passed('preferences'))
self.assertTrue(acid_block.scope_passed('user_info'))
def test_acid_block_preview(self):
"""
Verify that all expected acid block tests pass in studio preview
"""
self.outline.visit()
subsection = self.outline.section('Test Section').subsection('Test Subsection')
unit = subsection.toggle_expand().unit('Test Unit').go_to()
acid_block = AcidView(self.browser, unit.components[0].preview_selector)
self.validate_acid_block_preview(acid_block)
def test_acid_block_editor(self):
"""
Verify that all expected acid block tests pass in studio editor
"""
self.outline.visit()
subsection = self.outline.section('Test Section').subsection('Test Subsection')
unit = subsection.toggle_expand().unit('Test Unit').go_to()
unit.edit_draft()
acid_block = AcidView(self.browser, unit.components[0].edit().editor_selector)
self.assertTrue(acid_block.init_fn_passed)
self.assertTrue(acid_block.resource_url_passed)
self.assertTrue(acid_block.scope_passed('content'))
self.assertTrue(acid_block.scope_passed('settings'))
class XBlockAcidNoChildTest(XBlockAcidBase):
"""
Tests of an AcidBlock with no children
"""
__test__ = True
def setup_fixtures(self):
course_fix = CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
)
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
XBlockFixtureDesc('acid', 'Acid Block')
)
)
)
).install()
class XBlockAcidParentBase(XBlockAcidBase):
"""
Base class for tests that verify that parent XBlock integration is working correctly
"""
__test__ = False
def validate_acid_block_preview(self, acid_block):
super(XBlockAcidParentBase, self).validate_acid_block_preview(acid_block)
self.assertTrue(acid_block.child_tests_passed)
def test_acid_block_preview(self):
"""
Verify that all expected acid block tests pass in studio preview
"""
self.outline.visit()
subsection = self.outline.section('Test Section').subsection('Test Subsection')
unit = subsection.toggle_expand().unit('Test Unit').go_to()
container = unit.components[0].go_to_container()
acid_block = AcidView(self.browser, container.xblocks[0].preview_selector)
self.validate_acid_block_preview(acid_block)
@skip('This will fail until the container page supports editing')
def test_acid_block_editor(self):
super(XBlockAcidParentBase, self).test_acid_block_editor()
class XBlockAcidEmptyParentTest(XBlockAcidParentBase):
"""
Tests of an AcidBlock with children
"""
__test__ = True
def setup_fixtures(self):
course_fix = CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
)
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
XBlockFixtureDesc('acid_parent', 'Acid Parent Block').add_children(
)
)
)
)
).install()
class XBlockAcidChildTest(XBlockAcidParentBase):
"""
Tests of an AcidBlock with children
"""
__test__ = True
def setup_fixtures(self):
course_fix = CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
)
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
XBlockFixtureDesc('acid_parent', 'Acid Parent Block').add_children(
XBlockFixtureDesc('acid', 'First Acid Child', metadata={'name': 'first'}),
XBlockFixtureDesc('acid', 'Second Acid Child', metadata={'name': 'second'}),
XBlockFixtureDesc('html', 'Html Child', data="<html>Contents</html>"),
)
)
)
)
).install()
@skip('This will fail until we fix support of children in pure XBlocks')
def test_acid_block_preview(self):
super(XBlockAcidChildTest, self).test_acid_block_preview()
@skip('This will fail until we fix support of children in pure XBlocks')
def test_acid_block_editor(self):
super(XBlockAcidChildTest, self).test_acid_block_editor()
"""
Acceptance tests for Studio related to the container page.
"""
from ..pages.studio.auto_auth import AutoAuthPage
from ..pages.studio.overview import CourseOutlinePage
from ..fixtures.course import CourseFixture, XBlockFixtureDesc
from .helpers import UniqueCourseTest
class ContainerBase(UniqueCourseTest):
"""
Base class for tests that do operations on the container page.
"""
__test__ = False
def setUp(self):
"""
Create a unique identifier for the course used in this test.
"""
# Ensure that the superclass sets up
super(ContainerBase, self).setUp()
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.outline = CourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
self.container_title = ""
self.group_a = "Expand or Collapse\nGroup A"
self.group_b = "Expand or Collapse\nGroup B"
self.group_empty = "Expand or Collapse\nGroup Empty"
self.group_a_item_1 = "Group A Item 1"
self.group_a_item_2 = "Group A Item 2"
self.group_b_item_1 = "Group B Item 1"
self.group_b_item_2 = "Group B Item 2"
self.setup_fixtures()
self.auth_page.visit()
def setup_fixtures(self):
course_fix = CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
)
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
XBlockFixtureDesc('vertical', 'Test Container').add_children(
XBlockFixtureDesc('vertical', 'Group A').add_children(
XBlockFixtureDesc('html', self.group_a_item_1),
XBlockFixtureDesc('html', self.group_a_item_2)
),
XBlockFixtureDesc('vertical', 'Group Empty'),
XBlockFixtureDesc('vertical', 'Group B').add_children(
XBlockFixtureDesc('html', self.group_b_item_1),
XBlockFixtureDesc('html', self.group_b_item_2)
)
)
)
)
)
).install()
def go_to_container_page(self, make_draft=False):
self.outline.visit()
subsection = self.outline.section('Test Section').subsection('Test Subsection')
unit = subsection.toggle_expand().unit('Test Unit').go_to()
if make_draft:
unit.edit_draft()
container = unit.components[0].go_to_container()
return container
class DragAndDropTest(ContainerBase):
"""
Tests of reordering within the container page.
"""
__test__ = True
def verify_ordering(self, container, expected_orderings):
xblocks = container.xblocks
for expected_ordering in expected_orderings:
for xblock in xblocks:
parent = expected_ordering.keys()[0]
if xblock.name == parent:
children = xblock.children
expected_length = len(expected_ordering.get(parent))
self.assertEqual(
expected_length, len(children),
"Number of children incorrect for group {0}. Expected {1} but got {2}.".format(parent, expected_length, len(children)))
for idx, expected in enumerate(expected_ordering.get(parent)):
self.assertEqual(expected, children[idx].name)
break
def drag_and_verify(self, source, target, expected_ordering, after=True):
container = self.go_to_container_page(make_draft=True)
container.drag(source, target, after)
self.verify_ordering(container, expected_ordering)
# Reload the page to see that the reordering was saved persisted.
container = self.go_to_container_page()
self.verify_ordering(container, expected_ordering)
def test_reorder_in_group(self):
"""
Drag Group B Item 2 before Group B Item 1.
"""
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
{self.group_a: [self.group_a_item_1, self.group_a_item_2]},
{self.group_b: [self.group_b_item_2, self.group_b_item_1]},
{self.group_empty: []}]
self.drag_and_verify(6, 4, expected_ordering)
def test_drag_to_top(self):
"""
Drag Group A Item 1 to top level (outside of Group A).
"""
expected_ordering = [{self.container_title: [self.group_a_item_1, self.group_a, self.group_empty, self.group_b]},
{self.group_a: [self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}]
self.drag_and_verify(1, 0, expected_ordering, False)
def test_drag_into_different_group(self):
"""
Drag Group A Item 1 into Group B (last element).
"""
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
{self.group_a: [self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2, self.group_a_item_1]},
{self.group_empty: []}]
self.drag_and_verify(1, 6, expected_ordering)
def test_drag_group_into_group(self):
"""
Drag Group B into Group A (last element).
"""
expected_ordering = [{self.container_title: [self.group_a, self.group_empty]},
{self.group_a: [self.group_a_item_1, self.group_a_item_2, self.group_b]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}]
self.drag_and_verify(4, 2, expected_ordering)
# Not able to drag into the empty group with automation (difficult even outside of automation).
# def test_drag_into_empty(self):
# """
# Drag Group B Item 1 to Group Empty.
# """
# expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
# {self.group_a: [self.group_a_item_1, self.group_a_item_2]},
# {self.group_b: [self.group_b_item_2]},
# {self.group_empty: [self.group_b_item_1]}]
# self.drag_and_verify(6, 4, expected_ordering, False)
"""
Acceptance tests for Studio.
"""
from unittest import skip
from bok_choy.web_app_test import WebAppTest
from ..pages.studio.asset_index import AssetIndexPage
......@@ -22,7 +20,6 @@ from ..pages.studio.settings_advanced import AdvancedSettingsPage
from ..pages.studio.settings_graders import GradingPage
from ..pages.studio.signup import SignupPage
from ..pages.studio.textbooks import TextbooksPage
from ..pages.xblock.acid import AcidView
from ..fixtures.course import CourseFixture, XBlockFixtureDesc
from .helpers import UniqueCourseTest
......@@ -134,11 +131,11 @@ class DiscussionPreviewTest(UniqueCourseTest):
AutoAuthPage(self.browser, staff=True).visit()
cop = CourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
cop.visit()
self.unit = cop.section('Test Section').subsection('Test Subsection').toggle_expand().unit('Test Unit')
self.unit.go_to()
......@@ -149,198 +146,3 @@ class DiscussionPreviewTest(UniqueCourseTest):
"""
self.assertTrue(self.unit.q(css=".discussion-preview").present)
self.assertFalse(self.unit.q(css=".discussion-show").present)
class XBlockAcidBase(WebAppTest):
"""
Base class for tests that verify that XBlock integration is working correctly
"""
__test__ = False
def setUp(self):
"""
Create a unique identifier for the course used in this test.
"""
# Ensure that the superclass sets up
super(XBlockAcidBase, self).setUp()
# Define a unique course identifier
self.course_info = {
'org': 'test_org',
'number': 'course_' + self.unique_id[:5],
'run': 'test_' + self.unique_id,
'display_name': 'Test Course ' + self.unique_id
}
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.outline = CourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
self.course_id = '{org}.{number}.{run}'.format(**self.course_info)
self.setup_fixtures()
self.auth_page.visit()
def validate_acid_block_preview(self, acid_block):
"""
Validate the Acid Block's preview
"""
self.assertTrue(acid_block.init_fn_passed)
self.assertTrue(acid_block.resource_url_passed)
self.assertTrue(acid_block.scope_passed('user_state'))
self.assertTrue(acid_block.scope_passed('user_state_summary'))
self.assertTrue(acid_block.scope_passed('preferences'))
self.assertTrue(acid_block.scope_passed('user_info'))
def test_acid_block_preview(self):
"""
Verify that all expected acid block tests pass in studio preview
"""
self.outline.visit()
subsection = self.outline.section('Test Section').subsection('Test Subsection')
unit = subsection.toggle_expand().unit('Test Unit').go_to()
acid_block = AcidView(self.browser, unit.components[0].preview_selector)
self.validate_acid_block_preview(acid_block)
def test_acid_block_editor(self):
"""
Verify that all expected acid block tests pass in studio editor
"""
self.outline.visit()
subsection = self.outline.section('Test Section').subsection('Test Subsection')
unit = subsection.toggle_expand().unit('Test Unit').go_to()
unit.edit_draft()
acid_block = AcidView(self.browser, unit.components[0].edit().editor_selector)
self.assertTrue(acid_block.init_fn_passed)
self.assertTrue(acid_block.resource_url_passed)
self.assertTrue(acid_block.scope_passed('content'))
self.assertTrue(acid_block.scope_passed('settings'))
class XBlockAcidNoChildTest(XBlockAcidBase):
"""
Tests of an AcidBlock with no children
"""
__test__ = True
def setup_fixtures(self):
course_fix = CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
)
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
XBlockFixtureDesc('acid', 'Acid Block')
)
)
)
).install()
class XBlockAcidParentBase(XBlockAcidBase):
"""
Base class for tests that verify that parent XBlock integration is working correctly
"""
__test__ = False
def validate_acid_block_preview(self, acid_block):
super(XBlockAcidParentBase, self).validate_acid_block_preview(acid_block)
self.assertTrue(acid_block.child_tests_passed)
def test_acid_block_preview(self):
"""
Verify that all expected acid block tests pass in studio preview
"""
self.outline.visit()
subsection = self.outline.section('Test Section').subsection('Test Subsection')
unit = subsection.toggle_expand().unit('Test Unit').go_to()
container = unit.components[0].go_to_container()
acid_block = AcidView(self.browser, container.xblocks[0].preview_selector)
self.validate_acid_block_preview(acid_block)
@skip('This will fail until the container page supports editing')
def test_acid_block_editor(self):
super(XBlockAcidParentBase, self).test_acid_block_editor()
class XBlockAcidEmptyParentTest(XBlockAcidParentBase):
"""
Tests of an AcidBlock with children
"""
__test__ = True
def setup_fixtures(self):
course_fix = CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
)
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
XBlockFixtureDesc('acid_parent', 'Acid Parent Block').add_children(
)
)
)
)
).install()
class XBlockAcidChildTest(XBlockAcidParentBase):
"""
Tests of an AcidBlock with children
"""
__test__ = True
def setup_fixtures(self):
course_fix = CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
)
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
XBlockFixtureDesc('acid_parent', 'Acid Parent Block').add_children(
XBlockFixtureDesc('acid', 'First Acid Child', metadata={'name': 'first'}),
XBlockFixtureDesc('acid', 'Second Acid Child', metadata={'name': 'second'}),
XBlockFixtureDesc('html', 'Html Child', data="<html>Contents</html>"),
)
)
)
)
).install()
@skip('This will fail until we fix support of children in pure XBlocks')
def test_acid_block_preview(self):
super(XBlockAcidChildTest, self).test_acid_block_preview()
@skip('This will fail until we fix support of children in pure XBlocks')
def test_acid_block_editor(self):
super(XBlockAcidChildTest, self).test_acid_block_editor()
......@@ -14,7 +14,7 @@ html.video-fullscreen{
.wrap-instructor-info {
margin: ($baseline/2) ($baseline/4) 0 0;
overflow: hidden;
&.studio-view {
position: relative;
top: -($baseline/2);
......
<%!
from django.utils.translation import ugettext as _
%>
<div class="vert-mod">
<ol class="vertical-container">
% for idx, item in enumerate(items):
<li class="vertical-element is-draggable">
<div class="vert vert-${idx}" data-id="${item['id']}">
% if not xblock_context['read_only']:
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
% endif
${item['content']}
</div>
</li>
% endfor
</ol>
</div>
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