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, ...@@ -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 in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. 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. Common: Add extensible third-party auth module.
Blades: Handle situation if no response were sent from XQueue to LMS in Matlab Blades: Handle situation if no response were sent from XQueue to LMS in Matlab
......
...@@ -35,6 +35,7 @@ from ..utils import get_modulestore ...@@ -35,6 +35,7 @@ from ..utils import get_modulestore
from .access import has_course_access from .access import has_course_access
from .helpers import _xmodule_recurse from .helpers import _xmodule_recurse
from contentstore.utils import compute_publish_state, PublishState 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 contentstore.views.preview import get_preview_fragment
from edxmako.shortcuts import render_to_string from edxmako.shortcuts import render_to_string
from models.settings.course_grading import CourseGradingModel 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 ...@@ -193,6 +194,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
if 'application/json' in accept_header: if 'application/json' in accept_header:
store = get_modulestore(old_location) store = get_modulestore(old_location)
component = store.get_item(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 # wrap the generated fragment in the xmodule_editor div so that the javascript
# can bind to it correctly # can bind to it correctly
...@@ -212,12 +214,18 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v ...@@ -212,12 +214,18 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
store.update_item(component, None) store.update_item(component, None)
elif view_name == 'student_view' and component.has_children: 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 # For non-leaf xblocks on the unit page, show the special rendering
# which links to the new container page. # which links to the new container page.
html = render_to_string('container_xblock_component.html', { html = render_to_string('container_xblock_component.html', {
'xblock_context': context,
'xblock': component, 'xblock': component,
'locator': locator, 'locator': locator,
'reordering_enabled': True,
}) })
return JsonResponse({ return JsonResponse({
'html': html, 'html': html,
...@@ -225,8 +233,6 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v ...@@ -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'): elif view_name in ('student_view', 'container_preview'):
is_container_view = (view_name == '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 # 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 # 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 ...@@ -234,7 +240,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
context = { context = {
'runtime_type': 'studio', 'runtime_type': 'studio',
'container_view': is_container_view, 'container_view': is_container_view,
'read_only': is_read_only_view, 'read_only': is_read_only,
'root_xblock': component, 'root_xblock': component,
} }
...@@ -244,6 +250,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v ...@@ -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. # into the preview fragment, so we don't want to add another header here.
if not is_container_view: if not is_container_view:
fragment.content = render_to_string('component.html', { fragment.content = render_to_string('component.html', {
'xblock_context': context,
'preview': fragment.content, 'preview': fragment.content,
'label': component.display_name or component.scope_ids.block_type, '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 ...@@ -263,6 +270,17 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
return HttpResponse(status=406) 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, def _save_item(request, usage_loc, item_location, data=None, children=None, metadata=None, nullout=None,
grader_type=None, publish=None): grader_type=None, publish=None):
""" """
......
...@@ -2,10 +2,12 @@ ...@@ -2,10 +2,12 @@
Unit tests for the container view. Unit tests for the container view.
""" """
import json
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
from contentstore.utils import compute_publish_state, PublishState from contentstore.utils import compute_publish_state, PublishState
from contentstore.views.helpers import xblock_studio_url 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 from xmodule.modulestore.tests.factories import ItemFactory
...@@ -56,7 +58,6 @@ class ContainerViewTestCase(CourseTestCase): ...@@ -56,7 +58,6 @@ class ContainerViewTestCase(CourseTestCase):
parent_location=published_xblock_with_child.location, parent_location=published_xblock_with_child.location,
category="html", display_name="Child HTML" 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" branch_name = "MITx.999.Robot_Super_Course/branch/draft/block"
self._test_html_content( self._test_html_content(
published_xblock_with_child, published_xblock_with_child,
...@@ -73,6 +74,11 @@ class ContainerViewTestCase(CourseTestCase): ...@@ -73,6 +74,11 @@ class ContainerViewTestCase(CourseTestCase):
r'<a href="#" class="navigation-link navigation-current">Wrapper</a>' r'<a href="#" class="navigation-link navigation-current">Wrapper</a>'
).format(branch_name=branch_name) ).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( self._test_html_content(
draft_xblock_with_child, draft_xblock_with_child,
branch_name=branch_name, branch_name=branch_name,
...@@ -112,3 +118,37 @@ class ContainerViewTestCase(CourseTestCase): ...@@ -112,3 +118,37 @@ class ContainerViewTestCase(CourseTestCase):
branch_name=branch_name branch_name=branch_name
) )
self.assertIn(expected_unit_link, html) 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 ...@@ -4,6 +4,7 @@ import json
from contentstore.views import tabs from contentstore.views import tabs
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
from django.test import TestCase from django.test import TestCase
from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from courseware.courses import get_course_by_id from courseware.courses import get_course_by_id
from xmodule.tabs import CourseTabList, WikiTab from xmodule.tabs import CourseTabList, WikiTab
...@@ -22,7 +23,7 @@ class TabsPageTests(CourseTestCase): ...@@ -22,7 +23,7 @@ class TabsPageTests(CourseTestCase):
self.url = self.course_locator.url_reverse('tabs') self.url = self.course_locator.url_reverse('tabs')
# add a static tab to the course, for code coverage # add a static tab to the course, for code coverage
ItemFactory.create( self.test_tab = ItemFactory.create(
parent_location=self.course_location, parent_location=self.course_location,
category="static_tab", category="static_tab",
display_name="Static_1" display_name="Static_1"
...@@ -172,6 +173,25 @@ class TabsPageTests(CourseTestCase): ...@@ -172,6 +173,25 @@ class TabsPageTests(CourseTestCase):
) )
self.check_invalid_tab_id_response(resp) 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): class PrimitiveTabEdit(TestCase):
"""Tests for the primitive tab edit data manipulations""" """Tests for the primitive tab edit data manipulations"""
......
...@@ -18,6 +18,7 @@ requirejs.config({ ...@@ -18,6 +18,7 @@ requirejs.config({
"jquery.iframe-transport": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport", "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.inputnumber": "xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill",
"jquery.immediateDescendents": "xmodule_js/common_static/coffee/src/jquery.immediateDescendents", "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", "datepair": "xmodule_js/common_static/js/vendor/timepicker/datepair",
"date": "xmodule_js/common_static/js/vendor/date", "date": "xmodule_js/common_static/js/vendor/date",
"underscore": "xmodule_js/common_static/js/vendor/underscore-min", "underscore": "xmodule_js/common_static/js/vendor/underscore-min",
...@@ -100,6 +101,10 @@ requirejs.config({ ...@@ -100,6 +101,10 @@ requirejs.config({
deps: ["jquery"], deps: ["jquery"],
exports: "jQuery.fn.inputNumber" exports: "jQuery.fn.inputNumber"
}, },
"jquery.simulate": {
deps: ["jquery"],
exports: "jQuery.fn.simulate"
},
"jquery.tinymce": { "jquery.tinymce": {
deps: ["jquery", "tinymce"], deps: ["jquery", "tinymce"],
exports: "jQuery.fn.tinymce" exports: "jQuery.fn.tinymce"
...@@ -216,6 +221,7 @@ define([ ...@@ -216,6 +221,7 @@ define([
"js/spec/views/baseview_spec", "js/spec/views/baseview_spec",
"js/spec/views/paging_spec", "js/spec/views/paging_spec",
"js/spec/views/container_spec",
"js/spec/views/unit_spec", "js/spec/views/unit_spec",
"js/spec/views/xblock_spec", "js/spec/views/xblock_spec",
"js/spec/views/xblock_editor_spec", "js/spec/views/xblock_editor_spec",
......
...@@ -12,7 +12,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers ...@@ -12,7 +12,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
beforeEach(function () { beforeEach(function () {
edit_helpers.installEditTemplates(); 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({ model = new XBlockInfo({
id: 'testCourse/branch/draft/block/verticalFFF', id: 'testCourse/branch/draft/block/verticalFFF',
display_name: 'Test Unit', display_name: 'Test Unit',
......
/** /**
* Provides helper methods for invoking Studio modal windows in Jasmine tests. * Provides helper methods for invoking Studio modal windows in Jasmine tests.
*/ */
define(["jquery"], define(["jquery", "js/spec_helpers/view_helpers"],
function($) { function($, view_helpers) {
var basicModalTemplate = readFixtures('basic-modal.underscore'), var basicModalTemplate = readFixtures('basic-modal.underscore'),
modalButtonTemplate = readFixtures('modal-button.underscore'), modalButtonTemplate = readFixtures('modal-button.underscore'),
feedbackTemplate = readFixtures('system-feedback.underscore'), feedbackTemplate = readFixtures('system-feedback.underscore'),
...@@ -14,11 +14,7 @@ define(["jquery"], ...@@ -14,11 +14,7 @@ define(["jquery"],
cancelModalIfShowing; cancelModalIfShowing;
installModalTemplates = function(append) { installModalTemplates = function(append) {
if (append) { view_helpers.installViewTemplates(append);
appendSetFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate));
} else {
setFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate));
}
appendSetFixtures($("<script>", { id: "basic-modal-tpl", type: "text/template" }).text(basicModalTemplate)); appendSetFixtures($("<script>", { id: "basic-modal-tpl", type: "text/template" }).text(basicModalTemplate));
appendSetFixtures($("<script>", { id: "modal-button-tpl", type: "text/template" }).text(modalButtonTemplate)); appendSetFixtures($("<script>", { id: "modal-button-tpl", type: "text/template" }).text(modalButtonTemplate));
}; };
...@@ -58,11 +54,11 @@ define(["jquery"], ...@@ -58,11 +54,11 @@ define(["jquery"],
} }
}; };
return { return $.extend(view_helpers, {
'installModalTemplates': installModalTemplates, 'installModalTemplates': installModalTemplates,
'isShowingModal': isShowingModal, 'isShowingModal': isShowingModal,
'hideModalIfShowing': hideModalIfShowing, 'hideModalIfShowing': hideModalIfShowing,
'cancelModal': cancelModal, 'cancelModal': cancelModal,
'cancelModalIfShowing': cancelModalIfShowing '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 @@ ...@@ -2,8 +2,8 @@
* XBlockContainerView is used to display an xblock which has children, and allows the * XBlockContainerView is used to display an xblock which has children, and allows the
* user to interact with the children. * 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"], 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, XBlockView, EditXBlockModal, XBlockInfo) { function ($, _, gettext, NotificationView, PromptView, BaseView, ContainerView, XBlockView, EditXBlockModal, XBlockInfo) {
var XBlockContainerView = BaseView.extend({ var XBlockContainerView = BaseView.extend({
// takes XBlockInfo as a model // takes XBlockInfo as a model
...@@ -13,7 +13,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js ...@@ -13,7 +13,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
initialize: function() { initialize: function() {
BaseView.prototype.initialize.call(this); BaseView.prototype.initialize.call(this);
this.noContentElement = this.$('.no-container-content'); this.noContentElement = this.$('.no-container-content');
this.xblockView = new XBlockView({ this.xblockView = new ContainerView({
el: this.$('.wrapper-xblock'), el: this.$('.wrapper-xblock'),
model: this.model, model: this.model,
view: this.view view: this.view
...@@ -184,4 +184,3 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js ...@@ -184,4 +184,3 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
return XBlockContainerView; return XBlockContainerView;
}); // end define(); }); // end define();
...@@ -34,6 +34,7 @@ lib_paths: ...@@ -34,6 +34,7 @@ lib_paths:
- xmodule_js/common_static/js/vendor/jquery.min.js - 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-ui.min.js
- xmodule_js/common_static/js/vendor/jquery.cookie.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-min.js
- xmodule_js/common_static/js/vendor/underscore.string.min.js - xmodule_js/common_static/js/vendor/underscore.string.min.js
- xmodule_js/common_static/js/vendor/backbone-min.js - xmodule_js/common_static/js/vendor/backbone-min.js
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
// basic setup // basic setup
html { html {
font-size: 62.5%; 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 { body {
......
...@@ -227,11 +227,12 @@ ...@@ -227,11 +227,12 @@
.action-item { .action-item {
display: inline-block; display: inline-block;
vertical-align: middle;
.action-button { .action-button {
display: block;
border-radius: 3px; border-radius: 3px;
padding: ($baseline/4) ($baseline/2); padding: ($baseline/4) ($baseline/2);
height: ($baseline*1.5);
color: $gray-l1; color: $gray-l1;
&:hover { &:hover {
...@@ -248,6 +249,15 @@ ...@@ -248,6 +249,15 @@
background-color: $gray-l1; 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 @@ ...@@ -19,6 +19,7 @@
@include box-sizing(border-box); @include box-sizing(border-box);
@include ui-flexbox(); @include ui-flexbox();
@extend %ui-align-center-flex; @extend %ui-align-center-flex;
justify-content: space-between;
border-bottom: 1px solid $gray-l4; border-bottom: 1px solid $gray-l4;
border-radius: ($baseline/5) ($baseline/5) 0 0; border-radius: ($baseline/5) ($baseline/5) 0 0;
min-height: ($baseline*2.5); min-height: ($baseline*2.5);
...@@ -30,14 +31,14 @@ ...@@ -30,14 +31,14 @@
@extend %ui-justify-left-flex; @extend %ui-justify-left-flex;
@include ui-flexbox(); @include ui-flexbox();
width: flex-grid(6,12); width: flex-grid(6,12);
vertical-align: top; vertical-align: middle;
} }
.header-actions { .header-actions {
@include ui-flexbox(); @include ui-flexbox();
@extend %ui-justify-right-flex; @extend %ui-justify-right-flex;
width: flex-grid(6,12); width: flex-grid(6,12);
vertical-align: top; vertical-align: middle;
} }
} }
} }
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
// ==================== // ====================
// UI: container page view // UI: container page view
body.view-container { .view-container {
.mast { .mast {
border-bottom: none; border-bottom: none;
...@@ -97,7 +97,58 @@ body.view-container { ...@@ -97,7 +97,58 @@ body.view-container {
} }
// UI: xblock rendering // 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 { .wrapper-xblock {
@extend %wrap-xblock; @extend %wrap-xblock;
......
...@@ -26,6 +26,7 @@ ...@@ -26,6 +26,7 @@
</li> </li>
</ul> </ul>
</div> </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} ${preview}
...@@ -21,8 +21,7 @@ from contentstore.views.helpers import xblock_studio_url ...@@ -21,8 +21,7 @@ from contentstore.views.helpers import xblock_studio_url
</ul> </ul>
</div> </div>
</header> </header>
## We currently support reordering only on the unit page. % if not xblock_context['read_only']:
% if reordering_enabled:
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span> <span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
% endif % endif
</section> </section>
...@@ -154,7 +154,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -154,7 +154,7 @@ class DraftModuleStore(MongoModuleStore):
self.refresh_cached_metadata_inheritance_tree(draft_location) self.refresh_cached_metadata_inheritance_tree(draft_location)
self.fire_updated_modulestore_signal(get_course_id_no_run(draft_location), 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): def update_item(self, xblock, user=None, allow_not_found=False):
""" """
......
...@@ -18,6 +18,22 @@ class VerticalModule(VerticalFields, XModule): ...@@ -18,6 +18,22 @@ class VerticalModule(VerticalFields, XModule):
''' Layout module for laying out submodules vertically.''' ''' Layout module for laying out submodules vertically.'''
def student_view(self, context): 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() fragment = Fragment()
contents = [] contents = []
...@@ -33,8 +49,9 @@ class VerticalModule(VerticalFields, XModule): ...@@ -33,8 +49,9 @@ class VerticalModule(VerticalFields, XModule):
'content': rendered_child.content 'content': rendered_child.content
}) })
fragment.add_content(self.system.render_template('vert_module.html', { fragment.add_content(self.system.render_template(template_name, {
'items': contents 'items': contents,
'xblock_context': context,
})) }))
return fragment 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 ...@@ -6,6 +6,7 @@ from bok_choy.page_object import PageObject
from bok_choy.promise import Promise from bok_choy.promise import Promise
from . import BASE_URL from . import BASE_URL
from selenium.webdriver.common.action_chains import ActionChains
class ContainerPage(PageObject): class ContainerPage(PageObject):
""" """
...@@ -44,6 +45,24 @@ class ContainerPage(PageObject): ...@@ -44,6 +45,24 @@ class ContainerPage(PageObject):
return self.q(css=XBlockWrapper.BODY_SELECTOR).map( return self.q(css=XBlockWrapper.BODY_SELECTOR).map(
lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results 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): class XBlockWrapper(PageObject):
""" """
...@@ -79,5 +98,21 @@ class XBlockWrapper(PageObject): ...@@ -79,5 +98,21 @@ class XBlockWrapper(PageObject):
return None return None
@property @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): def preview_selector(self):
return self._bounded_selector('.xblock-student_view') 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. Acceptance tests for Studio.
""" """
from unittest import skip
from bok_choy.web_app_test import WebAppTest from bok_choy.web_app_test import WebAppTest
from ..pages.studio.asset_index import AssetIndexPage from ..pages.studio.asset_index import AssetIndexPage
...@@ -22,7 +20,6 @@ from ..pages.studio.settings_advanced import AdvancedSettingsPage ...@@ -22,7 +20,6 @@ from ..pages.studio.settings_advanced import AdvancedSettingsPage
from ..pages.studio.settings_graders import GradingPage from ..pages.studio.settings_graders import GradingPage
from ..pages.studio.signup import SignupPage from ..pages.studio.signup import SignupPage
from ..pages.studio.textbooks import TextbooksPage from ..pages.studio.textbooks import TextbooksPage
from ..pages.xblock.acid import AcidView
from ..fixtures.course import CourseFixture, XBlockFixtureDesc from ..fixtures.course import CourseFixture, XBlockFixtureDesc
from .helpers import UniqueCourseTest from .helpers import UniqueCourseTest
...@@ -134,11 +131,11 @@ class DiscussionPreviewTest(UniqueCourseTest): ...@@ -134,11 +131,11 @@ class DiscussionPreviewTest(UniqueCourseTest):
AutoAuthPage(self.browser, staff=True).visit() AutoAuthPage(self.browser, staff=True).visit()
cop = CourseOutlinePage( cop = CourseOutlinePage(
self.browser, self.browser,
self.course_info['org'], self.course_info['org'],
self.course_info['number'], self.course_info['number'],
self.course_info['run'] self.course_info['run']
) )
cop.visit() cop.visit()
self.unit = cop.section('Test Section').subsection('Test Subsection').toggle_expand().unit('Test Unit') self.unit = cop.section('Test Section').subsection('Test Subsection').toggle_expand().unit('Test Unit')
self.unit.go_to() self.unit.go_to()
...@@ -149,198 +146,3 @@ class DiscussionPreviewTest(UniqueCourseTest): ...@@ -149,198 +146,3 @@ class DiscussionPreviewTest(UniqueCourseTest):
""" """
self.assertTrue(self.unit.q(css=".discussion-preview").present) self.assertTrue(self.unit.q(css=".discussion-preview").present)
self.assertFalse(self.unit.q(css=".discussion-show").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{ ...@@ -14,7 +14,7 @@ html.video-fullscreen{
.wrap-instructor-info { .wrap-instructor-info {
margin: ($baseline/2) ($baseline/4) 0 0; margin: ($baseline/2) ($baseline/4) 0 0;
overflow: hidden; overflow: hidden;
&.studio-view { &.studio-view {
position: relative; position: relative;
top: -($baseline/2); 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