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

Drag and drop on container page
This commit is contained in:
Christina Roberts
2014-05-06 11:09:29 -04:00
28 changed files with 1424 additions and 283 deletions

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -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)

View File

@@ -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"""

View File

@@ -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",

View File

@@ -0,0 +1,215 @@
define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers",
"js/views/container", "js/models/xblock_info", "js/views/feedback_notification", "jquery.simulate",
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function ($, create_sinon, view_helpers, ContainerView, XBlockInfo, Notification) {
describe("Container View", function () {
describe("Supports reordering components", function () {
var model, containerView, mockContainerHTML, respondWithMockXBlockFragment, init, getComponent,
getDragHandle, dragComponentVertically, dragComponentAbove,
verifyRequest, verifyNumReorderCalls, respondToRequest,
rootLocator = 'testCourse/branch/draft/split_test/splitFFF',
containerTestUrl = '/xblock/' + rootLocator,
groupAUrl = "/xblock/locator-group-A",
groupA = "locator-group-A",
groupAComponent1 = "locator-component-A1",
groupAComponent2 = "locator-component-A2",
groupAComponent3 = "locator-component-A3",
groupBUrl = "/xblock/locator-group-B",
groupB = "locator-group-B",
groupBComponent1 = "locator-component-B1",
groupBComponent2 = "locator-component-B2",
groupBComponent3 = "locator-component-B3";
mockContainerHTML = readFixtures('mock/mock-container-xblock.underscore');
respondWithMockXBlockFragment = function (requests, response) {
var requestIndex = requests.length - 1;
create_sinon.respondWithJson(requests, response, requestIndex);
};
beforeEach(function () {
view_helpers.installViewTemplates();
appendSetFixtures('<div class="wrapper-xblock level-page" data-locator="' + rootLocator + '"></div>');
model = new XBlockInfo({
id: rootLocator,
display_name: 'Test AB Test',
category: 'split_test'
});
containerView = new ContainerView({
model: model,
view: 'container_preview',
el: $('.wrapper-xblock')
});
});
afterEach(function () {
containerView.remove();
});
init = function (caller) {
var requests = create_sinon.requests(caller);
containerView.render();
respondWithMockXBlockFragment(requests, {
html: mockContainerHTML,
"resources": []
});
$('body').append(containerView.$el);
return requests;
};
getComponent = function(locator) {
return containerView.$('[data-locator="' + locator + '"]');
};
getDragHandle = function(locator) {
var component = getComponent(locator);
return component.prev();
};
dragComponentVertically = function (locator, dy) {
var handle = getDragHandle(locator);
handle.simulate("drag", {dy: dy});
};
dragComponentAbove = function (sourceLocator, targetLocator) {
var targetElement = getComponent(targetLocator),
targetTop = targetElement.offset().top + 1,
handle = getDragHandle(sourceLocator),
handleY = handle.offset().top + (handle.height() / 2),
dy = targetTop - handleY;
handle.simulate("drag", {dy: dy});
};
verifyRequest = function (requests, reorderCallIndex, expectedURL, expectedChildren) {
var actualIndex, request, children, i;
// 0th call is the response to the initial render call to get HTML.
actualIndex = reorderCallIndex + 1;
expect(requests.length).toBeGreaterThan(actualIndex);
request = requests[actualIndex];
expect(request.url).toEqual(expectedURL);
children = (JSON.parse(request.requestBody)).children;
expect(children.length).toEqual(expectedChildren.length);
for (i = 0; i < children.length; i++) {
expect(children[i]).toEqual(expectedChildren[i]);
}
};
verifyNumReorderCalls = function (requests, expectedCalls) {
// Number of calls will be 1 more than expected because of the initial render call to get HTML.
expect(requests.length).toEqual(expectedCalls + 1);
};
respondToRequest = function (requests, reorderCallIndex, status) {
var actualIndex;
// Number of calls will be 1 more than expected because of the initial render call to get HTML.
actualIndex = reorderCallIndex + 1;
expect(requests.length).toBeGreaterThan(actualIndex);
requests[actualIndex].respond(status);
};
it('does nothing if item not moved far enough', function () {
var requests = init(this);
// Drag the first component in Group A down very slightly but not enough to move it.
dragComponentVertically(groupAComponent1, 5);
verifyNumReorderCalls(requests, 0);
});
it('can reorder within a group', function () {
var requests = init(this);
// Drag the third component in Group A to be the first
dragComponentAbove(groupAComponent3, groupAComponent1);
respondToRequest(requests, 0, 200);
verifyRequest(requests, 0, groupAUrl, [groupAComponent3, groupAComponent1, groupAComponent2]);
});
it('can drag from one group to another', function () {
var requests = init(this);
// Drag the first component in Group B to the top of group A.
dragComponentAbove(groupBComponent1, groupAComponent1);
// Respond to the two requests: add the component to Group A, then remove it from Group B.
respondToRequest(requests, 0, 200);
respondToRequest(requests, 1, 200);
verifyRequest(requests, 0, groupAUrl,
[groupBComponent1, groupAComponent1, groupAComponent2, groupAComponent3]);
verifyRequest(requests, 1, groupBUrl, [groupBComponent2, groupBComponent3]);
});
it('does not remove from old group if addition to new group fails', function () {
var requests = init(this);
// Drag the first component in Group B to the first group.
dragComponentAbove(groupBComponent1, groupAComponent1);
respondToRequest(requests, 0, 500);
// Send failure for addition to new group -- no removal event should be received.
verifyRequest(requests, 0, groupAUrl,
[groupBComponent1, groupAComponent1, groupAComponent2, groupAComponent3]);
// Verify that a second request was not issued
verifyNumReorderCalls(requests, 1);
});
it('can swap group A and group B', function () {
var requests = init(this);
// Drag Group B before group A.
dragComponentAbove(groupB, groupA);
respondToRequest(requests, 0, 200);
verifyRequest(requests, 0, containerTestUrl, [groupB, groupA]);
});
describe("Shows a saving message", function () {
var savingSpies;
beforeEach(function () {
savingSpies = spyOnConstructor(Notification, "Mini",
["show", "hide"]);
savingSpies.show.andReturn(savingSpies);
});
it('hides saving message upon success', function () {
var requests, savingOptions;
requests = init(this);
// Drag the first component in Group B to the first group.
dragComponentAbove(groupBComponent1, groupAComponent1);
expect(savingSpies.constructor).toHaveBeenCalled();
expect(savingSpies.show).toHaveBeenCalled();
expect(savingSpies.hide).not.toHaveBeenCalled();
savingOptions = savingSpies.constructor.mostRecentCall.args[0];
expect(savingOptions.title).toMatch(/Saving/);
respondToRequest(requests, 0, 200);
expect(savingSpies.hide).not.toHaveBeenCalled();
respondToRequest(requests, 1, 200);
expect(savingSpies.hide).toHaveBeenCalled();
});
it('does not hide saving message if failure', function () {
var requests = init(this);
// Drag the first component in Group B to the first group.
dragComponentAbove(groupBComponent1, groupAComponent1);
expect(savingSpies.constructor).toHaveBeenCalled();
expect(savingSpies.show).toHaveBeenCalled();
expect(savingSpies.hide).not.toHaveBeenCalled();
respondToRequest(requests, 0, 500);
expect(savingSpies.hide).not.toHaveBeenCalled();
// Since the first reorder call failed, the removal will not be called.
verifyNumReorderCalls(requests, 1);
});
});
});
});
});

View File

@@ -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',

View File

@@ -1,8 +1,8 @@
/**
* 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
};
});
});

View File

@@ -0,0 +1,20 @@
/**
* 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
};
});

View File

@@ -0,0 +1,115 @@
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();

View File

@@ -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();

View File

@@ -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

View File

@@ -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 {

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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}

View File

@@ -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>

View File

@@ -1,127 +1,222 @@
<header class="xblock-header"></header>
<article class="xblock-render">
<div class="xblock" data-block-type="vertical">
<div class="xblock" data-block-type="vertical" data-locator="locator-container">
<div class="vert-mod">
<div class="vert vert-0">
<ol class="vertical-container">
<li class="vertical-element is-draggable">
<div class="vert vert-0">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<div class="xblock" data-block-type="vertical">
<div class="vert-mod">
<ol class="vertical-container">
<li class="vertical-element is-draggable">
<div class="vert vert-0">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-nesting" data-locator="locator-group-A">
<header class="xblock-header"></header>
<div class="xblock" data-block-type="vertical">
<div class="vert-mod">
<div class="vert vert-0">
<article class="xblock-render">
<div class="xblock" data-block-type="vertical">
<div class="vert-mod">
<ol class="vertical-container">
<li class="vertical-element is-draggable">
<div class="vert vert-0">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element"
data-locator="locator-component-A1">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a
href="#"
class="edit-button action-button"></a>
</li>
<li class="action-item action-duplicate"><a
href="#"
class="duplicate-button action-button"></a>
</li>
<li class="action-item action-delete"><a
href="#"
class="delete-button action-button"></a>
</li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
<section class="wrapper-xblock level-nesting" data-locator="locator-group-A">
<header class="xblock-header"></header>
</li>
<li class="vertical-element is-draggable">
<div class="vert vert-1">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element"
data-locator="locator-component-A2">
<article class="xblock-render">
<div class="xblock" data-block-type="vertical">
<div class="vert-mod">
<div class="vert vert-0">
<section class="wrapper-xblock level-element" data-locator="locator-component-A1">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a href="#" class="edit-button action-button"></a></li>
<li class="action-item action-duplicate"><a href="#" class="duplicate-button action-button"></a></li>
<li class="action-item action-delete"><a href="#" class="delete-button action-button"></a></li>
</ul>
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a
href="#"
class="edit-button action-button"></a>
</li>
<li class="action-item action-duplicate"><a
href="#"
class="duplicate-button action-button"></a>
</li>
<li class="action-item action-delete"><a
href="#"
class="delete-button action-button"></a>
</li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
</li>
<li class="vertical-element is-draggable">
<div class="vert vert-2">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element"
data-locator="locator-component-A3">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a
href="#"
class="edit-button action-button"></a>
</li>
<li class="action-item action-duplicate"><a
href="#"
class="duplicate-button action-button"></a>
</li>
<li class="action-item action-delete"><a
href="#"
class="delete-button action-button"></a>
</li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
</li>
</ol>
</div>
</header>
<article class="xblock-render"></article>
</section>
<section class="wrapper-xblock level-element" data-locator="locator-component-A2">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a href="#" class="edit-button action-button"></a></li>
<li class="action-item action-duplicate"><a href="#" class="duplicate-button action-button"></a></li>
<li class="action-item action-delete"><a href="#" class="delete-button action-button"></a></li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
<section class="wrapper-xblock level-element" data-locator="locator-component-A3">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a href="#" class="edit-button action-button"></a></li>
<li class="action-item action-duplicate"><a href="#" class="duplicate-button action-button"></a></li>
<li class="action-item action-delete"><a href="#" class="delete-button action-button"></a></li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
</div>
</article>
</section>
</div>
</div>
</article>
</section>
</li>
<li class="vertical-element is-draggable">
<div class="vert vert-1">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-nesting" data-locator="locator-group-B">
<header class="xblock-header"></header>
<section class="wrapper-xblock level-nesting" data-locator="locator-group-B">
<header class="xblock-header"></header>
<article class="xblock-render">
<div class="xblock" data-block-type="vertical">
<div class="vert-mod">
<ol class="vertical-container">
<li class="vertical-element is-draggable">
<div class="vert vert-0">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element"
data-locator="locator-component-B1">
<article class="xblock-render">
<div class="xblock" data-block-type="vertical">
<div class="vert-mod">
<div class="vert vert-0">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a
href="#"
class="edit-button action-button"></a>
</li>
<li class="action-item action-duplicate"><a
href="#"
class="duplicate-button action-button"></a>
</li>
<li class="action-item action-delete"><a
href="#"
class="delete-button action-button"></a>
</li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
</li>
<li class="vertical-element is-draggable">
<div class="vert vert-1">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element"
data-locator="locator-component-B2">
<section class="wrapper-xblock level-element" data-locator="locator-component-B1">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a
href="#"
class="edit-button action-button"></a>
</li>
<li class="action-item action-duplicate"><a
href="#"
class="duplicate-button action-button"></a>
</li>
<li class="action-item action-delete"><a
href="#"
class="delete-button action-button"></a>
</li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
</li>
<li class="vertical-element is-draggable">
<div class="vert vert-2">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element"
data-locator="locator-component-B3">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a href="#" class="edit-button action-button"></a></li>
<li class="action-item action-duplicate"><a href="#" class="duplicate-button action-button"></a></li>
<li class="action-item action-delete"><a href="#" class="delete-button action-button"></a></li>
</ul>
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a
href="#"
class="edit-button action-button"></a>
</li>
<li class="action-item action-duplicate"><a
href="#"
class="duplicate-button action-button"></a>
</li>
<li class="action-item action-delete"><a
href="#"
class="delete-button action-button"></a>
</li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
</li>
</ol>
</div>
</header>
<article class="xblock-render"></article>
</section>
<section class="wrapper-xblock level-element" data-locator="locator-component-B2">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a href="#" class="edit-button action-button"></a></li>
<li class="action-item action-duplicate"><a href="#" class="duplicate-button action-button"></a></li>
<li class="action-item action-delete"><a href="#" class="delete-button action-button"></a></li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
<section class="wrapper-xblock level-element" data-locator="locator-component-B3">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a href="#" class="edit-button action-button"></a></li>
<li class="action-item action-duplicate"><a href="#" class="duplicate-button action-button"></a></li>
<li class="action-item action-delete"><a href="#" class="delete-button action-button"></a></li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
</div>
</article>
</section>
</div>
</div>
</article>
</section>
</li>
</ol>
</div>
</div>
</div>
</div>
</div>
</li>
</ol>
</div>
</div>
</article>

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -0,0 +1,312 @@
/*!
* 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 );

View 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):
"""
@@ -78,6 +97,22 @@ class XBlockWrapper(PageObject):
else:
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')

View File

@@ -1,155 +1,15 @@
"""
Acceptance tests for Studio.
Acceptance tests for Studio related to the acid xblock.
"""
from unittest import skip
from bok_choy.web_app_test import WebAppTest
from ..pages.studio.asset_index import AssetIndexPage
from ..pages.studio.auto_auth import AutoAuthPage
from ..pages.studio.checklists import ChecklistsPage
from ..pages.studio.course_import import ImportPage
from ..pages.studio.course_info import CourseUpdatesPage
from ..pages.studio.edit_tabs import PagesPage
from ..pages.studio.export import ExportPage
from ..pages.studio.howitworks import HowitworksPage
from ..pages.studio.index import DashboardPage
from ..pages.studio.login import LoginPage
from ..pages.studio.manage_users import CourseTeamPage
from ..pages.studio.overview import CourseOutlinePage
from ..pages.studio.settings import SettingsPage
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
class LoggedOutTest(WebAppTest):
"""
Smoke test for pages in Studio that are visible when logged out.
"""
def setUp(self):
super(LoggedOutTest, self).setUp()
self.pages = [LoginPage(self.browser), HowitworksPage(self.browser), SignupPage(self.browser)]
def test_page_existence(self):
"""
Make sure that all the pages are accessible.
Rather than fire up the browser just to check each url,
do them all sequentially in this testcase.
"""
for page in self.pages:
page.visit()
class LoggedInPagesTest(WebAppTest):
"""
Tests that verify the pages in Studio that you can get to when logged
in and do not have a course yet.
"""
def setUp(self):
super(LoggedInPagesTest, self).setUp()
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.dashboard_page = DashboardPage(self.browser)
def test_dashboard_no_courses(self):
"""
Make sure that you can get to the dashboard page without a course.
"""
self.auth_page.visit()
self.dashboard_page.visit()
class CoursePagesTest(UniqueCourseTest):
"""
Tests that verify the pages in Studio that you can get to when logged
in and have a course.
"""
COURSE_ID_SEPARATOR = "."
def setUp(self):
"""
Install a course with no content using a fixture.
"""
super(UniqueCourseTest, self).setUp()
CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
).install()
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.pages = [
clz(self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run'])
for clz in [
AssetIndexPage, ChecklistsPage, ImportPage, CourseUpdatesPage,
PagesPage, ExportPage, CourseTeamPage, CourseOutlinePage, SettingsPage,
AdvancedSettingsPage, GradingPage, TextbooksPage
]
]
def test_page_existence(self):
"""
Make sure that all these pages are accessible once you have a course.
Rather than fire up the browser just to check each url,
do them all sequentially in this testcase.
"""
# Log in
self.auth_page.visit()
# Verify that each page is available
for page in self.pages:
page.visit()
class DiscussionPreviewTest(UniqueCourseTest):
"""
Tests that Inline Discussions are rendered with a custom preview in Studio
"""
def setUp(self):
super(DiscussionPreviewTest, self).setUp()
CourseFixture(**self.course_info).add_children(
XBlockFixtureDesc("chapter", "Test Section").add_children(
XBlockFixtureDesc("sequential", "Test Subsection").add_children(
XBlockFixtureDesc("vertical", "Test Unit").add_children(
XBlockFixtureDesc(
"discussion",
"Test Discussion",
)
)
)
)
).install()
AutoAuthPage(self.browser, staff=True).visit()
cop = CourseOutlinePage(
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()
def test_is_preview(self):
"""
Ensure that the preview version of the discussion is rendered.
"""
self.assertTrue(self.unit.q(css=".discussion-preview").present)
self.assertFalse(self.unit.q(css=".discussion-show").present)
class XBlockAcidBase(WebAppTest):
"""

View File

@@ -0,0 +1,163 @@
"""
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)

View File

@@ -0,0 +1,148 @@
"""
Acceptance tests for Studio.
"""
from bok_choy.web_app_test import WebAppTest
from ..pages.studio.asset_index import AssetIndexPage
from ..pages.studio.auto_auth import AutoAuthPage
from ..pages.studio.checklists import ChecklistsPage
from ..pages.studio.course_import import ImportPage
from ..pages.studio.course_info import CourseUpdatesPage
from ..pages.studio.edit_tabs import PagesPage
from ..pages.studio.export import ExportPage
from ..pages.studio.howitworks import HowitworksPage
from ..pages.studio.index import DashboardPage
from ..pages.studio.login import LoginPage
from ..pages.studio.manage_users import CourseTeamPage
from ..pages.studio.overview import CourseOutlinePage
from ..pages.studio.settings import SettingsPage
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 ..fixtures.course import CourseFixture, XBlockFixtureDesc
from .helpers import UniqueCourseTest
class LoggedOutTest(WebAppTest):
"""
Smoke test for pages in Studio that are visible when logged out.
"""
def setUp(self):
super(LoggedOutTest, self).setUp()
self.pages = [LoginPage(self.browser), HowitworksPage(self.browser), SignupPage(self.browser)]
def test_page_existence(self):
"""
Make sure that all the pages are accessible.
Rather than fire up the browser just to check each url,
do them all sequentially in this testcase.
"""
for page in self.pages:
page.visit()
class LoggedInPagesTest(WebAppTest):
"""
Tests that verify the pages in Studio that you can get to when logged
in and do not have a course yet.
"""
def setUp(self):
super(LoggedInPagesTest, self).setUp()
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.dashboard_page = DashboardPage(self.browser)
def test_dashboard_no_courses(self):
"""
Make sure that you can get to the dashboard page without a course.
"""
self.auth_page.visit()
self.dashboard_page.visit()
class CoursePagesTest(UniqueCourseTest):
"""
Tests that verify the pages in Studio that you can get to when logged
in and have a course.
"""
COURSE_ID_SEPARATOR = "."
def setUp(self):
"""
Install a course with no content using a fixture.
"""
super(UniqueCourseTest, self).setUp()
CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
).install()
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.pages = [
clz(self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run'])
for clz in [
AssetIndexPage, ChecklistsPage, ImportPage, CourseUpdatesPage,
PagesPage, ExportPage, CourseTeamPage, CourseOutlinePage, SettingsPage,
AdvancedSettingsPage, GradingPage, TextbooksPage
]
]
def test_page_existence(self):
"""
Make sure that all these pages are accessible once you have a course.
Rather than fire up the browser just to check each url,
do them all sequentially in this testcase.
"""
# Log in
self.auth_page.visit()
# Verify that each page is available
for page in self.pages:
page.visit()
class DiscussionPreviewTest(UniqueCourseTest):
"""
Tests that Inline Discussions are rendered with a custom preview in Studio
"""
def setUp(self):
super(DiscussionPreviewTest, self).setUp()
CourseFixture(**self.course_info).add_children(
XBlockFixtureDesc("chapter", "Test Section").add_children(
XBlockFixtureDesc("sequential", "Test Subsection").add_children(
XBlockFixtureDesc("vertical", "Test Unit").add_children(
XBlockFixtureDesc(
"discussion",
"Test Discussion",
)
)
)
)
).install()
AutoAuthPage(self.browser, staff=True).visit()
cop = CourseOutlinePage(
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()
def test_is_preview(self):
"""
Ensure that the preview version of the discussion is rendered.
"""
self.assertTrue(self.unit.q(css=".discussion-preview").present)
self.assertFalse(self.unit.q(css=".discussion-show").present)

View File

@@ -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);

View File

@@ -0,0 +1,17 @@
<%!
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>