Commit 138cd459 by Andy Armstrong

Merge pull request #3819 from edx/andya/edit-containers

Allow editing of container xblocks/xmodules
parents 172c6d15 6b3e100c
...@@ -14,6 +14,8 @@ Blades: Remove Video player outline. BLD-975. ...@@ -14,6 +14,8 @@ Blades: Remove Video player outline. BLD-975.
Blades: Fix Youtube regular expression in video player editor. BLD-967. Blades: Fix Youtube regular expression in video player editor. BLD-967.
Studio: Support editing of containers. STUD-1312.
Blades: Fix displaying transcripts on touch devices. BLD-1033. Blades: Fix displaying transcripts on touch devices. BLD-1033.
Blades: Tolerance expressed in percentage now computes correctly. BLD-522. Blades: Tolerance expressed in percentage now computes correctly. BLD-522.
......
...@@ -199,25 +199,6 @@ def xblock_view_handler(request, usage_key_string, view_name): ...@@ -199,25 +199,6 @@ def xblock_view_handler(request, usage_key_string, view_name):
# change not authored by requestor but by xblocks. # change not authored by requestor but by xblocks.
store.update_item(xblock, None) store.update_item(xblock, None)
elif view_name == 'student_view' and xblock_has_own_studio_page(xblock):
context = {
'runtime_type': 'studio',
'container_view': False,
'read_only': is_read_only,
'root_xblock': xblock,
}
# 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': xblock,
'locator': usage_key,
})
return JsonResponse({
'html': html,
'resources': [],
})
elif view_name in (unit_views + container_views): elif view_name in (unit_views + container_views):
is_container_view = (view_name in container_views) is_container_view = (view_name in container_views)
...@@ -245,8 +226,15 @@ def xblock_view_handler(request, usage_key_string, view_name): ...@@ -245,8 +226,15 @@ def xblock_view_handler(request, usage_key_string, view_name):
# the component div. Note that the container view recursively adds headers # the component div. Note that the container view recursively adds headers
# 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', { # For non-leaf xblocks, show the special rendering which links to the new container page.
if xblock_has_own_studio_page(xblock):
template = 'container_xblock_component.html'
else:
template = 'component.html'
fragment.content = render_to_string(template, {
'xblock_context': context, 'xblock_context': context,
'xblock': xblock,
'locator': usage_key,
'preview': fragment.content, 'preview': fragment.content,
'label': xblock.display_name or xblock.scope_ids.block_type, 'label': xblock.display_name or xblock.scope_ids.block_type,
}) })
......
...@@ -180,12 +180,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): ...@@ -180,12 +180,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
'is_root': is_root, 'is_root': is_root,
'is_reorderable': is_reorderable, 'is_reorderable': is_reorderable,
} }
# For child xblocks with their own page, render a link to the page html = render_to_string('studio_xblock_wrapper.html', template_context)
if xblock_has_own_studio_page(xblock) and not is_root:
template = 'studio_container_wrapper.html'
else:
template = 'studio_xblock_wrapper.html'
html = render_to_string(template, template_context)
frag = wrap_fragment(frag, html) frag = wrap_fragment(frag, html)
return frag return frag
......
...@@ -137,8 +137,6 @@ class ContainerPageTestCase(StudioPageTestCase): ...@@ -137,8 +137,6 @@ class ContainerPageTestCase(StudioPageTestCase):
""" """
empty_child_container = ItemFactory.create(parent_location=self.vertical.location, empty_child_container = ItemFactory.create(parent_location=self.vertical.location,
category='split_test', display_name='Split Test') category='split_test', display_name='Split Test')
ItemFactory.create(parent_location=empty_child_container.location,
category='html', display_name='Split Child')
self.validate_preview_html(empty_child_container, self.reorderable_child_view, self.validate_preview_html(empty_child_container, self.reorderable_child_view,
can_reorder=False, can_edit=False, can_add=False) can_reorder=False, can_edit=False, can_add=False)
...@@ -148,9 +146,7 @@ class ContainerPageTestCase(StudioPageTestCase): ...@@ -148,9 +146,7 @@ class ContainerPageTestCase(StudioPageTestCase):
""" """
empty_child_container = ItemFactory.create(parent_location=self.vertical.location, empty_child_container = ItemFactory.create(parent_location=self.vertical.location,
category='split_test', display_name='Split Test') category='split_test', display_name='Split Test')
ItemFactory.create(parent_location=empty_child_container.location,
category='html', display_name='Split Child')
modulestore('draft').convert_to_draft(self.vertical.location) modulestore('draft').convert_to_draft(self.vertical.location)
draft_empty_child_container = modulestore('draft').convert_to_draft(empty_child_container.location) draft_empty_child_container = modulestore('draft').convert_to_draft(empty_child_container.location)
self.validate_preview_html(draft_empty_child_container, self.reorderable_child_view, self.validate_preview_html(draft_empty_child_container, self.reorderable_child_view,
can_reorder=True, can_edit=False, can_add=False) can_reorder=True, can_edit=True, can_add=False)
...@@ -60,7 +60,7 @@ class UnitPageTestCase(StudioPageTestCase): ...@@ -60,7 +60,7 @@ class UnitPageTestCase(StudioPageTestCase):
ItemFactory.create(parent_location=child_container.location, ItemFactory.create(parent_location=child_container.location,
category='html', display_name='grandchild') category='html', display_name='grandchild')
self.validate_preview_html(child_container, 'student_view', self.validate_preview_html(child_container, 'student_view',
can_reorder=True, can_edit=False, can_add=False) can_reorder=True, can_edit=True, can_add=False)
def test_draft_child_container_preview_html(self): def test_draft_child_container_preview_html(self):
""" """
...@@ -74,4 +74,4 @@ class UnitPageTestCase(StudioPageTestCase): ...@@ -74,4 +74,4 @@ class UnitPageTestCase(StudioPageTestCase):
modulestore('draft').convert_to_draft(self.vertical.location) modulestore('draft').convert_to_draft(self.vertical.location)
draft_child_container = modulestore('draft').get_item(child_container.location) draft_child_container = modulestore('draft').get_item(child_container.location)
self.validate_preview_html(draft_child_container, 'student_view', self.validate_preview_html(draft_child_container, 'student_view',
can_reorder=True, can_edit=False, can_add=False) can_reorder=True, can_edit=True, can_add=False)
...@@ -15,7 +15,11 @@ define ["jquery", "underscore", "gettext", "xblock/runtime.v1", ...@@ -15,7 +15,11 @@ define ["jquery", "underscore", "gettext", "xblock/runtime.v1",
@render() @render()
loadDisplay: -> loadDisplay: ->
XBlock.initializeBlock(@$el.find('.xblock-student_view')) # Not all components render an inline student view, e.g. child containers which
# instead render a link to a separate container page.
xblockElement = @$el.find('.xblock-student_view')
if xblockElement.length > 0
XBlock.initializeBlock(xblockElement)
createItem: (parent, payload, callback=->) -> createItem: (parent, payload, callback=->) ->
payload.parent_locator = parent payload.parent_locator = parent
......
...@@ -11,7 +11,7 @@ define(["backbone", "js/utils/module"], function(Backbone, ModuleUtils) { ...@@ -11,7 +11,7 @@ define(["backbone", "js/utils/module"], function(Backbone, ModuleUtils) {
"is_container": null, "is_container": null,
"data": null, "data": null,
"metadata" : null, "metadata" : null,
"children": [] "children": null
} }
}); });
return XBlockInfo; return XBlockInfo;
......
...@@ -11,7 +11,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers ...@@ -11,7 +11,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
getDragHandle, dragComponentVertically, dragComponentAbove, getDragHandle, dragComponentVertically, dragComponentAbove,
verifyRequest, verifyNumReorderCalls, respondToRequest, notificationSpy, verifyRequest, verifyNumReorderCalls, respondToRequest, notificationSpy,
rootLocator = 'testCourse/branch/draft/split_test/splitFFF', rootLocator = 'locator-container',
containerTestUrl = '/xblock/' + rootLocator, containerTestUrl = '/xblock/' + rootLocator,
groupAUrl = "/xblock/locator-group-A", groupAUrl = "/xblock/locator-group-A",
......
...@@ -7,6 +7,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers ...@@ -7,6 +7,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
model, containerPage, requests, model, containerPage, requests,
mockContainerPage = readFixtures('mock/mock-container-page.underscore'), mockContainerPage = readFixtures('mock/mock-container-page.underscore'),
mockContainerXBlockHtml = readFixtures('mock/mock-container-xblock.underscore'), mockContainerXBlockHtml = readFixtures('mock/mock-container-xblock.underscore'),
mockUpdatedContainerXBlockHtml = readFixtures('mock/mock-updated-container-xblock.underscore'),
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore'); mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore');
beforeEach(function () { beforeEach(function () {
...@@ -14,8 +15,8 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers ...@@ -14,8 +15,8 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
appendSetFixtures(mockContainerPage); appendSetFixtures(mockContainerPage);
model = new XBlockInfo({ model = new XBlockInfo({
id: 'testCourse/branch/draft/block/verticalFFF', id: 'locator-container',
display_name: 'Test Unit', display_name: 'Test Container',
category: 'vertical' category: 'vertical'
}); });
containerPage = new ContainerPage({ containerPage = new ContainerPage({
...@@ -51,13 +52,14 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers ...@@ -51,13 +52,14 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
}); });
}; };
describe("Basic display", function() { describe("Initial display", function() {
it('can render itself', function() { it('can render itself', function() {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(mockContainerXBlockHtml, this);
expect(containerPage.$el.select('.xblock-header')).toBeTruthy(); expect(containerPage.$el.select('.xblock-header')).toBeTruthy();
expect(containerPage.$('.wrapper-xblock')).not.toHaveClass('is-hidden'); expect(containerPage.$('.wrapper-xblock')).not.toHaveClass('is-hidden');
expect(containerPage.$('.no-container-content')).toHaveClass('is-hidden'); expect(containerPage.$('.no-container-content')).toHaveClass('is-hidden');
}); });
it('shows a loading indicator', function() { it('shows a loading indicator', function() {
requests = create_sinon.requests(this); requests = create_sinon.requests(this);
containerPage.render(); containerPage.render();
...@@ -67,6 +69,66 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers ...@@ -67,6 +69,66 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
}); });
}); });
describe("Editing the container", function() {
var newDisplayName = 'New Display Name';
beforeEach(function () {
edit_helpers.installMockXBlock({
data: "<p>Some HTML</p>",
metadata: {
display_name: newDisplayName
}
});
});
afterEach(function() {
edit_helpers.uninstallMockXBlock();
edit_helpers.cancelModalIfShowing();
});
it('can edit itself', function() {
var editButtons, modalElement,
updatedTitle = 'Updated Test Container';
renderContainerPage(mockContainerXBlockHtml, this);
// Click the root edit button
editButtons = containerPage.$('.nav-actions .edit-button');
editButtons.first().click();
modalElement = edit_helpers.getModalElement();
// Expect a request to be made to show the studio view for the container
expect(lastRequest().url).toBe(
'/xblock/locator-container/studio_view'
);
create_sinon.respondWithJson(requests, {
html: mockContainerXBlockHtml,
resources: []
});
expect(edit_helpers.isShowingModal()).toBeTruthy();
// Expect the correct title to be shown
expect(modalElement.find('.modal-window-title').text()).toBe('Editing: Test Container');
// Press the save button and respond with a success message to the save
edit_helpers.pressModalButton('.action-save');
create_sinon.respondWithJson(requests, { });
expect(edit_helpers.isShowingModal()).toBeFalsy();
// Expect the last request be to refresh the container page
expect(lastRequest().url).toBe(
'/xblock/locator-container/container_preview'
);
create_sinon.respondWithJson(requests, {
html: mockUpdatedContainerXBlockHtml,
resources: []
});
// Expect the title and breadcrumb to be updated
expect(containerPage.$('.page-header-title').text().trim()).toBe(updatedTitle);
expect(containerPage.$('.page-header .subtitle a').last().text().trim()).toBe(updatedTitle);
});
});
describe("Editing an xblock", function() { describe("Editing an xblock", function() {
var newDisplayName = 'New Display Name'; var newDisplayName = 'New Display Name';
...@@ -87,10 +149,10 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers ...@@ -87,10 +149,10 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
it('can show an edit modal for a child xblock', function() { it('can show an edit modal for a child xblock', function() {
var editButtons; var editButtons;
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(mockContainerXBlockHtml, this);
editButtons = containerPage.$('.edit-button'); editButtons = containerPage.$('.wrapper-xblock .edit-button');
// The container renders six mock xblocks, so there should be an equal number of edit buttons // The container should have rendered six mock xblocks
expect(editButtons.length).toBe(6); expect(editButtons.length).toBe(6);
editButtons.first().click(); editButtons[0].click();
// Make sure that the correct xblock is requested to be edited // Make sure that the correct xblock is requested to be edited
expect(lastRequest().url).toBe( expect(lastRequest().url).toBe(
'/xblock/locator-component-A1/studio_view' '/xblock/locator-component-A1/studio_view'
...@@ -125,10 +187,10 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers ...@@ -125,10 +187,10 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
var editButtons, modal, mockUpdatedXBlockHtml; var editButtons, modal, mockUpdatedXBlockHtml;
mockUpdatedXBlockHtml = readFixtures('mock/mock-updated-xblock.underscore'); mockUpdatedXBlockHtml = readFixtures('mock/mock-updated-xblock.underscore');
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(mockContainerXBlockHtml, this);
editButtons = containerPage.$('.edit-button'); editButtons = containerPage.$('.wrapper-xblock .edit-button');
// The container renders six mock xblocks, so there should be an equal number of edit buttons // The container should have rendered six mock xblocks
expect(editButtons.length).toBe(6); expect(editButtons.length).toBe(6);
editButtons.first().click(); editButtons[0].click();
create_sinon.respondWithJson(requests, { create_sinon.respondWithJson(requests, {
html: mockXModuleEditor, html: mockXModuleEditor,
resources: [] resources: []
......
define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/module_info", define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/module_info",
"js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers", "jasmine-stealth"], "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers", "jasmine-stealth"],
function ($, _, jasmine, UnitEditView, ModuleModel, create_sinon, edit_helpers) { function ($, _, jasmine, UnitEditView, ModuleModel, create_sinon, edit_helpers) {
var requests, unitView, initialize, respondWithHtml, verifyComponents, i; var requests, unitView, initialize, lastRequest, respondWithHtml, verifyComponents, i,
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore');
respondWithHtml = function(html, requestIndex) { respondWithHtml = function(html, requestIndex) {
create_sinon.respondWithJson( create_sinon.respondWithJson(
...@@ -13,6 +14,7 @@ define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/m ...@@ -13,6 +14,7 @@ define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/m
initialize = function(test) { initialize = function(test) {
var mockXBlockHtml = readFixtures('mock/mock-unit-page-xblock.underscore'), var mockXBlockHtml = readFixtures('mock/mock-unit-page-xblock.underscore'),
mockChildContainerHtml = readFixtures('mock/mock-unit-page-child-container.underscore'),
model; model;
requests = create_sinon.requests(test); requests = create_sinon.requests(test);
model = new ModuleModel({ model = new ModuleModel({
...@@ -25,11 +27,13 @@ define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/m ...@@ -25,11 +27,13 @@ define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/m
model: model model: model
}); });
// Respond with renderings for the two xblocks in the unit // Respond with renderings for the two xblocks in the unit (the second is itself a child container)
respondWithHtml(mockXBlockHtml, 0); respondWithHtml(mockXBlockHtml, 0);
respondWithHtml(mockXBlockHtml, 1); respondWithHtml(mockChildContainerHtml, 1);
}; };
lastRequest = function() { return requests[requests.length - 1]; };
verifyComponents = function (unit, locators) { verifyComponents = function (unit, locators) {
var components = unit.$(".component"); var components = unit.$(".component");
expect(components.length).toBe(locators.length); expect(components.length).toBe(locators.length);
...@@ -174,5 +178,93 @@ define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/m ...@@ -174,5 +178,93 @@ define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/m
test_link_disabled_during_ajax_call(draft_states[i]); test_link_disabled_during_ajax_call(draft_states[i]);
} }
}); });
describe("Editing an xblock", function() {
var newDisplayName = 'New Display Name';
beforeEach(function () {
edit_helpers.installMockXBlock({
data: "<p>Some HTML</p>",
metadata: {
display_name: newDisplayName
}
});
});
afterEach(function() {
edit_helpers.uninstallMockXBlock();
edit_helpers.cancelModalIfShowing();
});
it('can show an edit modal for a child xblock', function() {
var editButtons;
initialize(this);
editButtons = unitView.$('.edit-button');
// The container renders two mock xblocks
expect(editButtons.length).toBe(2);
editButtons[1].click();
// Make sure that the correct xblock is requested to be edited
expect(lastRequest().url).toBe(
'/xblock/loc_2/studio_view'
);
create_sinon.respondWithJson(requests, {
html: mockXBlockEditorHtml,
resources: []
});
expect(edit_helpers.isShowingModal()).toBeTruthy();
});
});
describe("Editing an xmodule", function() {
var mockXModuleEditor = readFixtures('mock/mock-xmodule-editor.underscore'),
newDisplayName = 'New Display Name';
beforeEach(function () {
edit_helpers.installMockXModule({
data: "<p>Some HTML</p>",
metadata: {
display_name: newDisplayName
}
});
});
afterEach(function() {
edit_helpers.uninstallMockXModule();
edit_helpers.cancelModalIfShowing();
});
it('can save changes to settings', function() {
var editButtons, modal, mockUpdatedXBlockHtml;
mockUpdatedXBlockHtml = readFixtures('mock/mock-updated-xblock.underscore');
initialize(this);
editButtons = unitView.$('.edit-button');
// The container renders two mock xblocks
expect(editButtons.length).toBe(2);
editButtons[1].click();
create_sinon.respondWithJson(requests, {
html: mockXModuleEditor,
resources: []
});
modal = $('.edit-xblock-modal');
// Click on the settings tab
modal.find('.settings-button').click();
// Change the display name's text
modal.find('.setting-input').text("Mock Update");
// Press the save button
modal.find('.action-save').click();
// Respond to the save
create_sinon.respondWithJson(requests, {
id: 'mock-id'
});
// Respond to the request to refresh
respondWithHtml(mockUpdatedXBlockHtml);
// Verify that the xblock was updated
expect(unitView.$('.mock-updated-content').text()).toBe('Mock Update');
});
});
}); });
}); });
...@@ -9,11 +9,17 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers ...@@ -9,11 +9,17 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
var installMockXBlock, uninstallMockXBlock, installMockXModule, uninstallMockXModule, var installMockXBlock, uninstallMockXBlock, installMockXModule, uninstallMockXModule,
mockComponentTemplates, installEditTemplates, showEditModal, verifyXBlockRequest; mockComponentTemplates, installEditTemplates, showEditModal, verifyXBlockRequest;
installMockXBlock = function() { installMockXBlock = function(mockResult) {
window.MockXBlock = function(runtime, element) { window.MockXBlock = function(runtime, element) {
return { var block = {
runtime: runtime runtime: runtime
}; };
if (mockResult) {
block.save = function() {
return mockResult;
};
}
return block;
}; };
}; };
......
...@@ -7,6 +7,7 @@ define(["jquery", "js/spec_helpers/view_helpers"], ...@@ -7,6 +7,7 @@ define(["jquery", "js/spec_helpers/view_helpers"],
getModalElement, getModalElement,
isShowingModal, isShowingModal,
hideModalIfShowing, hideModalIfShowing,
pressModalButton,
cancelModal, cancelModal,
cancelModalIfShowing; cancelModalIfShowing;
...@@ -37,12 +38,16 @@ define(["jquery", "js/spec_helpers/view_helpers"], ...@@ -37,12 +38,16 @@ define(["jquery", "js/spec_helpers/view_helpers"],
} }
}; };
cancelModal = function(modal) { pressModalButton = function(selector, modal) {
var modalElement, cancelButton; var modalElement, button;
modalElement = getModalElement(modal); modalElement = getModalElement(modal);
cancelButton = modalElement.find('.action-cancel:visible'); button = modalElement.find(selector + ':visible');
expect(cancelButton.length).toBe(1); expect(button.length).toBe(1);
cancelButton.click(); button.click();
};
cancelModal = function(modal) {
pressModalButton('.action-cancel', modal);
}; };
cancelModalIfShowing = function(modal) { cancelModalIfShowing = function(modal) {
...@@ -52,9 +57,11 @@ define(["jquery", "js/spec_helpers/view_helpers"], ...@@ -52,9 +57,11 @@ define(["jquery", "js/spec_helpers/view_helpers"],
}; };
return $.extend(view_helpers, { return $.extend(view_helpers, {
'getModalElement': getModalElement,
'installModalTemplates': installModalTemplates, 'installModalTemplates': installModalTemplates,
'isShowingModal': isShowingModal, 'isShowingModal': isShowingModal,
'hideModalIfShowing': hideModalIfShowing, 'hideModalIfShowing': hideModalIfShowing,
'pressModalButton': pressModalButton,
'cancelModal': cancelModal, 'cancelModal': cancelModal,
'cancelModalIfShowing': cancelModalIfShowing 'cancelModalIfShowing': cancelModalIfShowing
}); });
......
define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification"], define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification"],
function ($, _, XBlockView, ModuleUtils, gettext, NotificationView) { function ($, _, XBlockView, ModuleUtils, gettext, NotificationView) {
var reorderableClass = '.reorderable-container', var reorderableClass = '.reorderable-container',
sortableInitializedClass = '.ui-sortable',
studioXBlockWrapperClass = '.studio-xblock-wrapper'; studioXBlockWrapperClass = '.studio-xblock-wrapper';
var ContainerView = XBlockView.extend({ var ContainerView = XBlockView.extend({
...@@ -8,7 +9,7 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -8,7 +9,7 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
xblockReady: function () { xblockReady: function () {
XBlockView.prototype.xblockReady.call(this); XBlockView.prototype.xblockReady.call(this);
var reorderableContainer = this.$(reorderableClass), var reorderableContainer = this.$(reorderableClass),
alreadySortable = this.$('.ui-sortable'), alreadySortable = this.$(sortableInitializedClass),
newParent, newParent,
oldParent, oldParent,
self = this; self = this;
...@@ -113,7 +114,7 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -113,7 +114,7 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
}, },
refresh: function() { refresh: function() {
this.$(reorderableClass).sortable('refresh'); this.$(sortableInitializedClass).sortable('refresh');
} }
}); });
......
...@@ -111,14 +111,10 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", ...@@ -111,14 +111,10 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
}, },
getTitle: function() { getTitle: function() {
var displayName = this.xblockElement.find('.xblock-header .header-details').text().trim(); var displayName = this.xblockInfo.get('display_name');
// If not found, try the old unit page style rendering
if (!displayName) {
displayName = this.xblockElement.find('.component-header').text().trim();
if (!displayName) { if (!displayName) {
displayName = gettext('Component'); displayName = gettext('Component');
} }
}
return interpolate(gettext("Editing: %(title)s"), { title: displayName }, true); return interpolate(gettext("Editing: %(title)s"), { title: displayName }, true);
}, },
...@@ -180,13 +176,16 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", ...@@ -180,13 +176,16 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
findXBlockInfo: function(xblockWrapperElement, defaultXBlockInfo) { findXBlockInfo: function(xblockWrapperElement, defaultXBlockInfo) {
var xblockInfo = defaultXBlockInfo, var xblockInfo = defaultXBlockInfo,
xblockElement; xblockElement,
displayName;
if (xblockWrapperElement.length > 0) { if (xblockWrapperElement.length > 0) {
xblockElement = xblockWrapperElement.find('.xblock'); xblockElement = xblockWrapperElement.find('.xblock');
displayName = xblockWrapperElement.find('.xblock-header .header-details').text().trim();
xblockInfo = new XBlockInfo({ xblockInfo = new XBlockInfo({
id: xblockWrapperElement.data('locator'), id: xblockWrapperElement.data('locator'),
courseKey: xblockWrapperElement.data('course-key'), courseKey: xblockWrapperElement.data('course-key'),
category: xblockElement.data('block-type') category: xblockElement.data('block-type'),
display_name: displayName
}); });
} }
return xblockInfo; return xblockInfo;
......
...@@ -46,6 +46,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", ...@@ -46,6 +46,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
} else { } else {
noContentElement.removeClass('is-hidden'); noContentElement.removeClass('is-hidden');
} }
self.refreshTitle();
loadingElement.addClass('is-hidden'); loadingElement.addClass('is-hidden');
self.delegateEvents(); self.delegateEvents();
} }
...@@ -60,6 +61,12 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", ...@@ -60,6 +61,12 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
return this.xblockView.model.urlRoot; return this.xblockView.model.urlRoot;
}, },
refreshTitle: function() {
var title = this.$('.xblock-header .header-details span').first().text().trim();
this.$('.page-header-title').text(title);
this.$('.page-header .subtitle a').last().text(title);
},
onXBlockRefresh: function(xblockView) { onXBlockRefresh: function(xblockView) {
this.addButtonActions(xblockView.$el); this.addButtonActions(xblockView.$el);
this.xblockView.refresh(); this.xblockView.refresh();
...@@ -177,10 +184,9 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", ...@@ -177,10 +184,9 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
*/ */
refreshXBlock: function(xblockElement) { refreshXBlock: function(xblockElement) {
var parentElement = xblockElement.parent(), var parentElement = xblockElement.parent(),
rootLocator = this.xblockView.model.id, rootLocator = this.xblockView.model.id;
xblockLocator = xblockElement.data('locator'); if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) {
if (xblockLocator === rootLocator) { this.render({ });
this.render();
} else if (parentElement.hasClass('reorderable-container')) { } else if (parentElement.hasClass('reorderable-container')) {
this.refreshChildXBlock(xblockElement); this.refreshChildXBlock(xblockElement);
} else { } else {
......
...@@ -334,6 +334,7 @@ p, ul, ol, dl { ...@@ -334,6 +334,7 @@ p, ul, ol, dl {
.navigation-link { .navigation-link {
@extend %cont-truncated; @extend %cont-truncated;
display: inline-block; display: inline-block;
vertical-align: bottom; // correct for extra padding in FF
max-width: 250px; max-width: 250px;
&.navigation-current { &.navigation-current {
......
...@@ -230,6 +230,7 @@ ...@@ -230,6 +230,7 @@
vertical-align: middle; vertical-align: middle;
.action-button { .action-button {
@include transition(all $tmg-f3 linear 0s);
display: block; display: block;
border-radius: 3px; border-radius: 3px;
padding: ($baseline/4) ($baseline/2); padding: ($baseline/4) ($baseline/2);
......
...@@ -15,11 +15,8 @@ ...@@ -15,11 +15,8 @@
} }
// UI: xblock header // UI: xblock header
.xblock-header { .xblock-header-primary {
@include box-sizing(border-box); @include box-sizing(border-box);
@include ui-flexbox();
@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);
...@@ -28,17 +25,73 @@ ...@@ -28,17 +25,73 @@
.header-details { .header-details {
@extend %cont-truncated; @extend %cont-truncated;
@extend %ui-justify-left-flex; display: inline-block;
@include ui-flexbox(); width: 50%;
width: flex-grid(6,12);
vertical-align: middle; vertical-align: middle;
} }
.header-actions { .header-actions {
@include ui-flexbox(); display: inline-block;
@extend %ui-justify-right-flex; width: 49%;
width: flex-grid(6,12);
vertical-align: middle; vertical-align: middle;
text-align: right;
}
}
.xblock-header-secondary {
overflow: hidden;
border-top: 1px solid $gray-l3;
background-color: $gray-l5;
padding: ($baseline/2) $baseline;
.meta-info {
display: inline-block;
vertical-align: middle;
width: 65%;
font-style: italic;
color: $gray;
}
.actions-list {
width: 34%;
display: inline-block;
vertical-align: middle;
text-align: right;
.action-item {
display: inline-block;
.action-button {
@include transition(all $tmg-f3 linear 0s);
display: block;
width: auto;
height: ($baseline*1.5);
border-radius: 3px;
padding: 3px ($baseline/2) 0 ($baseline/2);
color: $gray-l1;
&:hover {
background-color: $blue;
color: $gray-l6;
}
.action-button-text {
display: inline-block;
vertical-align: middle;
padding: 0 1px;
text-transform: uppercase;
}
&.delete-button:hover {
background-color: $gray-l1;
}
}
[class^="icon-"] {
display: inline-block;
vertical-align: middle;
}
}
} }
} }
} }
......
...@@ -12,13 +12,42 @@ ...@@ -12,13 +12,42 @@
.mast { .mast {
border-bottom: none; border-bottom: none;
padding-bottom: 0; padding-bottom: 0;
.page-header {
@extend %t-title;
@include font-size(28);
@include line-height(32);
.subtitle .navigation-link {
color: $gray;
&:hover {
color: $blue;
}
}
}
} }
.wrapper-mast .mast.has-navigation .nav-actions { .wrapper-mast .mast.has-navigation .nav-actions {
bottom: -($baseline*.75);
.nav-item .button { .nav-item {
.edit-button {
@include blue-button;
@extend %t-action4;
padding: ($baseline/4) ($baseline/2);
text-align: center;
.action-button-text {
display: inline-block;
vertical-align: middle;
}
[class^="icon-"] {
display: inline-block;
vertical-align: middle;
}
}
} }
} }
...@@ -140,6 +169,10 @@ body.view-container .content-primary { ...@@ -140,6 +169,10 @@ body.view-container .content-primary {
} }
.xblock-header { .xblock-header {
display: block;
}
.xblock-header-primary {
@include ui-flexbox(); @include ui-flexbox();
margin-bottom: 0; margin-bottom: 0;
border-bottom: none; border-bottom: none;
...@@ -168,6 +201,10 @@ body.view-container .content-primary { ...@@ -168,6 +201,10 @@ body.view-container .content-primary {
} }
.xblock-header { .xblock-header {
display: block;
}
.xblock-header-primary {
display: flex; display: flex;
margin-bottom: 0; margin-bottom: 0;
border-bottom: 1px solid $gray-l4; border-bottom: 1px solid $gray-l4;
...@@ -183,7 +220,7 @@ body.view-container .content-primary { ...@@ -183,7 +220,7 @@ body.view-container .content-primary {
// STATE: xBlock containers styled as rows. // STATE: xBlock containers styled as rows.
&.xblock-type-container { &.xblock-type-container {
.xblock-header { .xblock-header-primary {
margin-bottom: 0; margin-bottom: 0;
border-bottom: 0; border-bottom: 0;
border-radius: ($baseline/5); border-radius: ($baseline/5);
......
...@@ -750,13 +750,15 @@ body.unit { ...@@ -750,13 +750,15 @@ body.unit {
body.unit { body.unit {
.component-actions { .component-actions,
.xblock-header-secondary .actions-list {
.action-item { .action-item {
display: inline-block; display: inline-block;
margin: ($baseline/4) 0 ($baseline/4) ($baseline/4); margin: ($baseline/4) 0 ($baseline/4) ($baseline/4);
.action-button { .action-button {
@include transition(all $tmg-f3 linear 0s);
display: block; display: block;
padding: 0 $baseline/2; padding: 0 $baseline/2;
width: auto; width: auto;
...@@ -770,10 +772,10 @@ body.unit { ...@@ -770,10 +772,10 @@ body.unit {
} }
.action-button-text { .action-button-text {
padding-left: 1px; display: inline-block;
vertical-align: bottom; vertical-align: middle;
padding: 0 1px;
text-transform: uppercase; text-transform: uppercase;
line-height: 17px;
} }
&.delete-button:hover { &.delete-button:hover {
...@@ -783,7 +785,7 @@ body.unit { ...@@ -783,7 +785,7 @@ body.unit {
[class^="icon-"] { [class^="icon-"] {
display: inline-block; display: inline-block;
vertical-align: bottom; vertical-align: middle;
} }
} }
} }
...@@ -1128,7 +1130,7 @@ body.unit .xblock-type-container { ...@@ -1128,7 +1130,7 @@ body.unit .xblock-type-container {
} }
} }
.xblock-header { .xblock-header-primary {
border-bottom: 0; border-bottom: 0;
border-radius: ($baseline/5); border-radius: ($baseline/5);
...@@ -1178,6 +1180,44 @@ body.unit .component.editing { ...@@ -1178,6 +1180,44 @@ body.unit .component.editing {
} }
} }
// UI: special case xblock with no preview, ex. experiment blocks
body.unit .component {
.wrapper-component-action-header.nopreview {
position: relative;
border-bottom: 0;
}
.xblock-header-secondary {
overflow: hidden;
border-top: 1px solid $gray-l3;
background-color: $gray-l5;
padding: ($baseline/2);
.meta-info {
display: inline-block;
vertical-align: middle;
width: 65%;
padding-left: ($baseline/4);
font-style: italic;
color: $gray;
}
.actions-list {
display: inline-block;
vertical-align: middle;
width: 33%;
text-align: right;
.action-item {
margin: 0;
}
}
}
}
body.view-unit .main-column .unit-body, body.view-unit .main-column .unit-body,
body.view-container { body.view-container {
......
...@@ -54,9 +54,9 @@ main_xblock_info = { ...@@ -54,9 +54,9 @@ main_xblock_info = {
<div class="wrapper-mast wrapper" data-location="" data-display-name="" data-category=""> <div class="wrapper-mast wrapper" data-location="" data-display-name="" data-category="">
<header class="mast has-actions has-navigation"> <header class="mast has-actions has-navigation has-subtitle">
<h1 class="page-header"> <h1 class="page-header">
<small class="navigation navigation-parents"> <small class="navigation navigation-parents subtitle">
% for ancestor in ancestor_xblocks: % for ancestor in ancestor_xblocks:
<% <%
ancestor_url = xblock_studio_url(ancestor) ancestor_url = xblock_studio_url(ancestor)
...@@ -68,11 +68,20 @@ main_xblock_info = { ...@@ -68,11 +68,20 @@ main_xblock_info = {
% endfor % endfor
<a href="#" class="navigation-link navigation-current">${xblock.display_name_with_default | h}</a> <a href="#" class="navigation-link navigation-current">${xblock.display_name_with_default | h}</a>
</small> </small>
<span class="page-header-title">${xblock.display_name_with_default | h}</span>
</h1> </h1>
<nav class="nav-actions"> <nav class="nav-actions">
<h3 class="sr">${_("Page Actions")}</h3> <h3 class="sr">${_("Page Actions")}</h3>
<ul> <ul>
% if not unit_publish_state == 'public':
<li class="action-item action-edit nav-item">
<a href="#" class="button edit-button action-button">
<i class="icon-pencil"></i>
<span class="action-button-text">${_("Edit")}</span>
</a>
</li>
% endif
</ul> </ul>
</nav> </nav>
</header> </header>
......
...@@ -4,12 +4,33 @@ from contentstore.views.helpers import xblock_studio_url ...@@ -4,12 +4,33 @@ from contentstore.views.helpers import xblock_studio_url
%> %>
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<section class="wrapper-xblock xblock-type-container level-element" data-locator="${xblock.location}" data-course-key="${xblock.location.course_key}"> <section class="wrapper wrapper-xblock wrapper-component-action-header nopreview" data-locator="${locator}" data-course-key="${xblock.location.course_key}">
<header class="xblock-header"> <div class="component-header">
<div class="header-details">
${xblock.display_name_with_default} ${xblock.display_name_with_default}
</div> </div>
<div class="header-actions"> <ul class="component-actions">
<li class="action-item action-edit">
<a href="#" class="edit-button action-button">
<i class="icon-pencil"></i>
<span class="action-button-text">${_("Edit")}</span>
</a>
</li>
<li class="action-item action-duplicate">
<a href="#" data-tooltip="${_("Duplicate")}" class="duplicate-button action-button">
<i class="icon-copy"></i>
<span class="sr">${_("Duplicate")}</span>
</a>
</li>
<li class="action-item action-delete">
<a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button">
<i class="icon-trash"></i>
<span class="sr">${_("Delete")}</span>
</a>
</li>
</ul>
</section>
<div class="xblock-header-secondary">
<div class="meta-info">${_('This block contains multiple components.')}</div>
<ul class="actions-list"> <ul class="actions-list">
<li class="action-item action-view"> <li class="action-item action-view">
<a href="${xblock_studio_url(xblock)}" class="action-button"> <a href="${xblock_studio_url(xblock)}" class="action-button">
...@@ -18,10 +39,6 @@ from contentstore.views.helpers import xblock_studio_url ...@@ -18,10 +39,6 @@ from contentstore.views.helpers import xblock_studio_url
<i class="icon-arrow-right"></i> <i class="icon-arrow-right"></i>
</a> </a>
</li> </li>
<li class="action-item action-drag">
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
</li>
</ul> </ul>
</div> </div>
</header> <span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
</section>
...@@ -3,15 +3,22 @@ ...@@ -3,15 +3,22 @@
<div class="wrapper-mast wrapper" data-location="" data-display-name="" data-category=""> <div class="wrapper-mast wrapper" data-location="" data-display-name="" data-category="">
<header class="mast has-actions has-navigation"> <header class="mast has-actions has-navigation">
<h1 class="page-header"> <h1 class="page-header">
<small class="navigation navigation-parents"> <small class="navigation navigation-parents subtitle">
<a href="/unit/TestCourse/branch/draft/block/vertical8eb" class="navigation-link navigation-parent">Unit 1</a> <a href="/unit/TestCourse/branch/draft/block/vertical8eb" class="navigation-link navigation-parent">Unit 1</a>
<a href="#" class="navigation-link navigation-current">Nested Vertical Test</a> <a href="#" class="navigation-link navigation-current">Test Container</a>
</small> </small>
<span class="page-header-title">Test Container</span>
</h1> </h1>
<nav class="nav-actions"> <nav class="nav-actions">
<h3 class="sr">Page Actions</h3> <h3 class="sr">Page Actions</h3>
<ul> <ul>
<li class="action-item action-edit nav-item">
<a href="#" class="button edit-button action-button">
<i class="icon-pencil"></i>
<span class="action-button-text">${_("Edit")}</span>
</a>
</li>
</ul> </ul>
</nav> </nav>
</header> </header>
...@@ -22,7 +29,7 @@ ...@@ -22,7 +29,7 @@
<section class="content-area"> <section class="content-area">
<article class="content-primary window"> <article class="content-primary window">
<section class="wrapper-xblock level-page studio-xblock-wrapper" data-locator="TestCourse/branch/draft/block/vertical131"> <section class="wrapper-xblock level-page studio-xblock-wrapper" data-locator="locator-container">
</section> </section>
<div class="no-container-content is-hidden"> <div class="no-container-content is-hidden">
<p>This page has no content yet.</p> <p>This page has no content yet.</p>
......
<section class="wrapper wrapper-xblock wrapper-component-action-header nopreview" data-locator="locator-child-container">
<div class="component-header">
Test Child Container
</div>
<ul class="component-actions">
<li class="action-item action-edit">
<a href="#" class="edit-button action-button">
<i class="icon-pencil"></i>
<span class="action-button-text">Edit</span>
</a>
</li>
<li class="action-item action-duplicate">
<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button">
<i class="icon-copy"></i>
<span class="sr">Duplicate</span>
</a>
</li>
<li class="action-item action-delete">
<a href="#" data-tooltip="Delete" class="delete-button action-button">
<i class="icon-trash"></i>
<span class="sr">Delete</span>
</a>
</li>
</ul>
</section>
<div class="xblock-header-secondary">
<div class="meta-info">This block contains multiple components.</div>
<ul class="actions-list">
<li class="action-item action-view">
<a href="/container/locator-child-container" class="action-button">
<span class="action-button-text">View</span>
<i class="icon-arrow-right"></i>
</a>
</li>
</ul>
</div>
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<header class="xblock-header">
<div class="xblock-header-primary">
<div class="header-details">
<span>Updated Test Container</span>
</div>
<div class="header-actions">
<ul class="actions-list">
</ul>
</div>
</div>
</header>
<article class="xblock-render">
<div class="xblock" data-block-type="vertical" data-locator="locator-container"
data-init="MockXBlock" data-runtime-class="StudioRuntime" data-runtime-version="1">
<ol class="reorderable-container">
</ol>
</div>
</article>
<%!
from django.utils.translation import ugettext as _
from contentstore.views.helpers import xblock_studio_url
%>
<%namespace name='static' file='static_content.html'/>
% if is_reorderable:
<li class="studio-xblock-wrapper is-draggable" data-locator="${xblock.location}">
% else:
<div class="studio-xblock-wrapper">
% endif
<section class="wrapper-xblock xblock-type-container level-element" data-locator="${xblock.location}">
<header class="xblock-header">
<div class="header-details">
${xblock.display_name_with_default}
</div>
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-view">
<a href="${xblock_studio_url(xblock)}" class="action-button">
## Translators: this is a verb describing the action of viewing more details
<span class="action-button-text">${_('View')}</span>
<i class="icon-arrow-right"></i>
</a>
</li>
% if not xblock_context['read_only'] and is_reorderable:
<li class="action-item action-drag">
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
</li>
% endif
</ul>
</div>
</header>
</section>
% if is_reorderable:
</li>
% else:
</div>
% endif
<%! from django.utils.translation import ugettext as _ %> <%!
from django.utils.translation import ugettext as _
from contentstore.views.helpers import xblock_studio_url
%>
<%
xblock_url = xblock_studio_url(xblock)
show_inline = xblock.has_children and not xblock_url
section_class = "level-nesting" if show_inline else "level-element"
collapsible_class = "is-collapsible" if xblock.has_children else ""
%>
% if not is_root: % if not is_root:
% if is_reorderable: % if is_reorderable:
...@@ -7,16 +17,13 @@ ...@@ -7,16 +17,13 @@
<div class="studio-xblock-wrapper" data-locator="${xblock.location}"> <div class="studio-xblock-wrapper" data-locator="${xblock.location}">
% endif % endif
<%
section_class = "level-nesting" if xblock.has_children else "level-element"
collapsible_class = "is-collapsible" if xblock.has_children else ""
%>
<section class="wrapper-xblock ${section_class} ${collapsible_class}" data-course-key="${xblock.location.course_key}"> <section class="wrapper-xblock ${section_class} ${collapsible_class}" data-course-key="${xblock.location.course_key}">
% endif % endif
<header class="xblock-header"> <header class="xblock-header">
<div class="xblock-header-primary">
<div class="header-details"> <div class="header-details">
% if xblock.has_children: % if show_inline:
<a href="#" data-tooltip="${_('Expand or Collapse')}" class="action expand-collapse collapse"> <a href="#" data-tooltip="${_('Expand or Collapse')}" class="action expand-collapse collapse">
<i class="icon-caret-down ui-toggle-expansion"></i> <i class="icon-caret-down ui-toggle-expansion"></i>
<span class="sr">${_('Expand or Collapse')}</span> <span class="sr">${_('Expand or Collapse')}</span>
...@@ -26,8 +33,8 @@ ...@@ -26,8 +33,8 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
% if not xblock_context['read_only']: % if not xblock_context['read_only'] and not is_root:
% if not xblock.has_children: % if not show_inline:
<li class="action-item action-edit"> <li class="action-item action-edit">
<a href="#" class="edit-button action-button"> <a href="#" class="edit-button action-button">
<i class="icon-pencil"></i> <i class="icon-pencil"></i>
...@@ -47,7 +54,7 @@ ...@@ -47,7 +54,7 @@
</a> </a>
</li> </li>
% endif % endif
% if not is_root and is_reorderable: % if is_reorderable:
<li class="action-item action-drag"> <li class="action-item action-drag">
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span> <span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
</li> </li>
...@@ -55,10 +62,27 @@ ...@@ -55,10 +62,27 @@
% endif % endif
</ul> </ul>
</div> </div>
</div>
% if xblock_url and not is_root:
<div class="xblock-header-secondary">
<div class="meta-info">${_('This block contains multiple components.')}</div>
<ul class="actions-list">
<li class="action-item action-view">
<a href="${xblock_url}" class="action-button">
## Translators: this is a verb describing the action of viewing more details
<span class="action-button-text">${_('View')}</span>
<i class="icon-arrow-right"></i>
</a>
</li>
</ul>
</div>
% endif
</header> </header>
% if is_root or not xblock_url:
<article class="xblock-render"> <article class="xblock-render">
${content} ${content}
</article> </article>
% endif
% if not is_root: % if not is_root:
</section> </section>
......
<section class="sequence-edit"> <section class="sequence-edit">
<section class="filters wip">
<ul>
<li>
<h2>Sort:</h2>
<select>
<option value="">Linear Order</option>
<option value="">Recently Modified</option>
<option value="">Type</option>
<option value="">Alphabetically</option>
</select>
</li>
<li>
<h2>Filter:</h2>
<select>
<option value="">All content</option>
<option value="">Videos</option>
<option value="">Problems</option>
<option value="">Labs</option>
<option value="">Tutorials</option>
<option value="">HTML</option>
</select>
<a href="#" class="more">More</a>
</li>
<li class="search">
<input type="search" name="" id="" value="" placeholder="Search" />
</li>
</ul>
</section>
<div class="content">
<section class="modules">
<ol>
<li>
<ol id="sortable">
% for child in module.get_children():
<li class="${module.scope_ids.block_type}">
<a href="#" class="module-edit"
data-id="${child.location}"
data-type="${child.js_module_name}"
data-preview-type="${child.module_class.js_module_name}">${child.display_name_with_default}</a>
<a href="#" class="draggable">handle</a>
</li>
%endfor
</ol>
</li>
</ol>
</section>
</div>
<%include file="metadata-edit.html" /> <%include file="metadata-edit.html" />
</section> </section>
class @SequenceDescriptor extends XModule.Descriptor class @SequenceDescriptor extends XModule.Descriptor
constructor: (@element) ->
@$tabs = $(@element).find("#sequence-list")
@$tabs.sortable(
update: (event, ui) => @update()
)
save: ->
children: $('#sequence-list li a', @element).map((idx, el) -> $(el).data('id')).toArray()
class @VerticalDescriptor extends XModule.Descriptor class @VerticalDescriptor extends XModule.Descriptor
constructor: (@element) ->
@$items = $(@element).find(".vert-mod")
@$items.sortable(
update: (event, ui) => @update()
)
save: ->
children: $('.vert-mod div', @element).map((idx, el) -> $(el).data('id')).toArray()
class @WrapperDescriptor extends XModule.Descriptor
constructor: (@element) ->
console.log 'WrapperDescriptor'
@$items = $(@element).find(".vert-mod")
@$items.sortable(
update: (event, ui) => @update()
)
save: ->
children: $('.vert-mod div', @element).map((idx, el) -> $(el).data('id')).toArray()
...@@ -8,12 +8,13 @@ from webob import Response ...@@ -8,12 +8,13 @@ from webob import Response
from xmodule.progress import Progress from xmodule.progress import Progress
from xmodule.seq_module import SequenceDescriptor from xmodule.seq_module import SequenceDescriptor
from xmodule.studio_editable import StudioEditableModule
from xmodule.x_module import XModule, module_attr from xmodule.x_module import XModule, module_attr
from lxml import etree from lxml import etree
from xblock.core import XBlock from xblock.core import XBlock
from xblock.fields import Scope, Integer, ReferenceValueDict from xblock.fields import Scope, Integer, String, ReferenceValueDict
from xblock.fragment import Fragment from xblock.fragment import Fragment
log = logging.getLogger('edx.' + __name__) log = logging.getLogger('edx.' + __name__)
...@@ -23,6 +24,13 @@ class SplitTestFields(object): ...@@ -23,6 +24,13 @@ class SplitTestFields(object):
"""Fields needed for split test module""" """Fields needed for split test module"""
has_children = True has_children = True
display_name = String(
display_name="Display Name",
help="This name appears in the horizontal navigation at the top of the page.",
scope=Scope.settings,
default="Experiment Block"
)
user_partition_id = Integer( user_partition_id = Integer(
help="Which user partition is used for this test", help="Which user partition is used for this test",
scope=Scope.content scope=Scope.content
...@@ -45,7 +53,7 @@ class SplitTestFields(object): ...@@ -45,7 +53,7 @@ class SplitTestFields(object):
@XBlock.needs('user_tags') # pylint: disable=abstract-method @XBlock.needs('user_tags') # pylint: disable=abstract-method
@XBlock.wants('partitions') @XBlock.wants('partitions')
class SplitTestModule(SplitTestFields, XModule): class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
""" """
Show the user the appropriate child. Uses the ExperimentState Show the user the appropriate child. Uses the ExperimentState
API to figure out which child to show. API to figure out which child to show.
...@@ -177,21 +185,10 @@ class SplitTestModule(SplitTestFields, XModule): ...@@ -177,21 +185,10 @@ class SplitTestModule(SplitTestFields, XModule):
Renders the Studio preview by rendering each child so that they can all be seen and edited. Renders the Studio preview by rendering each child so that they can all be seen and edited.
""" """
fragment = Fragment() fragment = Fragment()
contents = [] # Only render the children when this block is being shown as the container
root_xblock = context.get('root_xblock')
for child in self.descriptor.get_children(): if root_xblock and root_xblock.location == self.location:
rendered_child = self.runtime.get_module(child).render('student_view', context) self.render_children(context, fragment, can_reorder=False)
fragment.add_frag_resources(rendered_child)
contents.append({
'id': child.location.to_deprecated_string(),
'content': rendered_child.content
})
fragment.add_content(self.system.render_template('vert_module.html', {
'items': contents
}))
return fragment return fragment
def student_view(self, context): def student_view(self, context):
...@@ -296,3 +293,11 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor): ...@@ -296,3 +293,11 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
makes it use module.get_child_descriptors(). makes it use module.get_child_descriptors().
""" """
return True return True
@property
def non_editable_metadata_fields(self):
non_editable_fields = super(SplitTestDescriptor, self).non_editable_metadata_fields
non_editable_fields.extend([
SplitTestDescriptor.due,
])
return non_editable_fields
...@@ -5,25 +5,34 @@ Mixin to support editing in Studio. ...@@ -5,25 +5,34 @@ Mixin to support editing in Studio.
class StudioEditableModule(object): class StudioEditableModule(object):
""" """
Helper methods for supporting Studio editing of xblocks. Helper methods for supporting Studio editing of xblocks/xmodules.
This class is only intended to be used with an XModule, as it assumes the existence of
self.descriptor and self.system.
""" """
def render_reorderable_children(self, context, fragment): def render_children(self, context, fragment, can_reorder=False, can_add=False, view_name='student_view'):
""" """
Renders children with the appropriate HTML structure for drag and drop. Renders the children of the module with HTML appropriate for Studio. If can_reorder is True,
then the children will be rendered to support drag and drop.
""" """
contents = [] contents = []
for child in self.get_display_items(): for child in self.descriptor.get_children(): # pylint: disable=E1101
if can_reorder:
context['reorderable_items'].add(child.location) context['reorderable_items'].add(child.location)
rendered_child = child.render('student_view', context) child_module = self.system.get_module(child) # pylint: disable=E1101
rendered_child = child_module.render(view_name, context)
fragment.add_frag_resources(rendered_child) fragment.add_frag_resources(rendered_child)
contents.append({ contents.append({
'id': child.location.to_deprecated_string(),
'content': rendered_child.content 'content': rendered_child.content
}) })
fragment.add_content(self.system.render_template("studio_render_children_view.html", { fragment.add_content(self.system.render_template("studio_render_children_view.html", { # pylint: disable=E1101
'items': contents, 'items': contents,
'xblock_context': context, 'xblock_context': context,
'can_add': can_add,
'can_reorder': can_reorder,
})) }))
...@@ -43,7 +43,7 @@ class SplitTestModuleTest(XModuleXmlImportTest): ...@@ -43,7 +43,7 @@ class SplitTestModuleTest(XModuleXmlImportTest):
xml.HtmlFactory(parent=split_test, url_name='split_test_cond1', text='HTML FOR GROUP 1') xml.HtmlFactory(parent=split_test, url_name='split_test_cond1', text='HTML FOR GROUP 1')
self.course = self.process_xml(course) self.course = self.process_xml(course)
course_seq = self.course.get_children()[0] self.course_sequence = self.course.get_children()[0]
self.module_system = get_test_system() self.module_system = get_test_system()
def get_module(descriptor): def get_module(descriptor):
...@@ -71,7 +71,7 @@ class SplitTestModuleTest(XModuleXmlImportTest): ...@@ -71,7 +71,7 @@ class SplitTestModuleTest(XModuleXmlImportTest):
) )
self.module_system._services['partitions'] = self.partitions_service # pylint: disable=protected-access self.module_system._services['partitions'] = self.partitions_service # pylint: disable=protected-access
self.split_test_module = course_seq.get_children()[0] self.split_test_module = self.course_sequence.get_children()[0]
self.split_test_module.bind_for_student(self.module_system, self.split_test_module._field_data) # pylint: disable=protected-access self.split_test_module.bind_for_student(self.module_system, self.split_test_module._field_data) # pylint: disable=protected-access
@ddt.data(('0', 'split_test_cond0'), ('1', 'split_test_cond1')) @ddt.data(('0', 'split_test_cond0'), ('1', 'split_test_cond1'))
...@@ -147,3 +147,40 @@ class SplitTestModuleTest(XModuleXmlImportTest): ...@@ -147,3 +147,40 @@ class SplitTestModuleTest(XModuleXmlImportTest):
self.assertEquals(fields.get('user_partition_id'), '0') self.assertEquals(fields.get('user_partition_id'), '0')
self.assertIsNotNone(fields.get('group_id_to_child')) self.assertIsNotNone(fields.get('group_id_to_child'))
self.assertEquals(len(children), 2) self.assertEquals(len(children), 2)
def test_render_studio_view(self):
"""
Test the rendering of the Studio view.
"""
# The split_test module should render both its groups when it is the root
reorderable_items = set()
context = {
'runtime_type': 'studio',
'container_view': True,
'reorderable_items': reorderable_items,
'root_xblock': self.split_test_module,
}
html = self.module_system.render(self.split_test_module, 'student_view', context).content
self.assertIn('HTML FOR GROUP 0', html)
self.assertIn('HTML FOR GROUP 1', html)
# When rendering as a child, it shouldn't render either of its groups
reorderable_items = set()
context = {
'runtime_type': 'studio',
'container_view': True,
'reorderable_items': reorderable_items,
'root_xblock': self.course_sequence,
}
html = self.module_system.render(self.split_test_module, 'student_view', context).content
self.assertNotIn('HTML FOR GROUP 0', html)
self.assertNotIn('HTML FOR GROUP 1', html)
def test_settings(self):
"""
Test the settings configuration.
"""
non_editable_metadata_fields = self.split_test_module.non_editable_metadata_fields
self.assertIn(SplitTestDescriptor.due, non_editable_metadata_fields)
self.assertNotIn(SplitTestDescriptor.display_name, non_editable_metadata_fields)
...@@ -54,9 +54,20 @@ class VerticalModuleTestCase(BaseVerticalModuleTest): ...@@ -54,9 +54,20 @@ class VerticalModuleTestCase(BaseVerticalModuleTest):
""" """
Test the rendering of the Studio view Test the rendering of the Studio view
""" """
# Vertical shouldn't render children on the unit page
context = {
'runtime_type': 'studio',
'container_view': False,
}
html = self.module_system.render(self.vertical, 'student_view', context).content
self.assertNotIn(self.test_html_1, html)
self.assertNotIn(self.test_html_2, html)
# Vertical should render reorderable children on the container page
reorderable_items = set() reorderable_items = set()
context = { context = {
'runtime_type': 'studio', 'runtime_type': 'studio',
'container_view': True,
'reorderable_items': reorderable_items, 'reorderable_items': reorderable_items,
} }
html = self.module_system.render(self.vertical, 'student_view', context).content html = self.module_system.render(self.vertical, 'student_view', context).content
......
...@@ -30,7 +30,10 @@ class VerticalModule(VerticalFields, XModule, StudioEditableModule): ...@@ -30,7 +30,10 @@ class VerticalModule(VerticalFields, XModule, StudioEditableModule):
Renders the Studio preview view, which supports drag and drop. Renders the Studio preview view, which supports drag and drop.
""" """
fragment = Fragment() fragment = Fragment()
self.render_reorderable_children(context, fragment) # For the container page we want the full drag-and-drop, but for unit pages we want
# a more concise version that appears alongside the "View =>" link.
if context.get('container_view'):
self.render_children(context, fragment, can_reorder=True, can_add=True)
return fragment return fragment
def render_view(self, context, template_name): def render_view(self, context, template_name):
...@@ -82,3 +85,11 @@ class VerticalDescriptor(VerticalFields, SequenceDescriptor): ...@@ -82,3 +85,11 @@ class VerticalDescriptor(VerticalFields, SequenceDescriptor):
# TODO (victor): Does this need its own definition_to_xml method? Otherwise it looks # TODO (victor): Does this need its own definition_to_xml method? Otherwise it looks
# like verticals will get exported as sequentials... # like verticals will get exported as sequentials...
@property
def non_editable_metadata_fields(self):
non_editable_fields = super(VerticalDescriptor, self).non_editable_metadata_fields
non_editable_fields.extend([
VerticalDescriptor.due,
])
return non_editable_fields
...@@ -20,7 +20,3 @@ class WrapperDescriptor(VerticalDescriptor): ...@@ -20,7 +20,3 @@ class WrapperDescriptor(VerticalDescriptor):
module_class = WrapperModule module_class = WrapperModule
has_children = True has_children = True
js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]}
js_module_name = "VerticalDescriptor"
...@@ -54,19 +54,19 @@ ...@@ -54,19 +54,19 @@
// extends - justify-content right for display:flex alignment in older browsers // extends - justify-content right for display:flex alignment in older browsers
%ui-justify-right-flex { %ui-justify-right-flex {
-webkit-box-pack: end; -webkit-box-pack: flex-end;
-moz-box-pack: end; -moz-box-pack: flex-end;
-ms-flex-pack: end; -ms-flex-pack: flex-end;
-webkit-justify-content: end; -webkit-justify-content: flex-end;
justify-content: flex-end; justify-content: flex-end;
} }
// extends - justify-content left for display:flex alignment in older browsers // extends - justify-content left for display:flex alignment in older browsers
%ui-justify-left-flex { %ui-justify-left-flex {
-webkit-box-pack: start; -webkit-box-pack: flex-start;
-moz-box-pack: start; -moz-box-pack: flex-start;
-ms-flex-pack: start; -ms-flex-pack: flex-start;
-webkit-justify-content: start; -webkit-justify-content: flex-start;
justify-content: flex-start; justify-content: flex-start;
} }
......
from bok_choy.page_object import PageObject
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.action_chains import ActionChains
from utils import click_css
class ComponentEditorView(PageObject):
"""
A :class:`.PageObject` representing the rendered view of a component editor.
This class assumes that the editor is our default editor as displayed for xmodules.
"""
BODY_SELECTOR = '.xblock-editor'
def __init__(self, browser, locator):
"""
Args:
browser (selenium.webdriver): The Selenium-controlled browser that this page is loaded in.
locator (str): The locator that identifies which xblock this :class:`.xblock-editor` relates to.
"""
super(ComponentEditorView, self).__init__(browser)
self.locator = locator
def is_browser_on_page(self):
return self.q(css='{}[data-locator="{}"]'.format(self.BODY_SELECTOR, self.locator)).present
def _bounded_selector(self, selector):
"""
Return `selector`, but limited to this particular `ComponentEditorView` context
"""
return '{}[data-locator="{}"] {}'.format(
self.BODY_SELECTOR,
self.locator,
selector
)
def url(self):
"""
Returns None because this is not directly accessible via URL.
"""
return None
def get_setting_entry_index(self, label):
"""
Returns the index of the setting entry with given label (display name) within the Settings modal.
"""
# TODO: will need to handle tabbed "Settings" in future (current usage is in vertical, only shows Settings.
setting_labels = self.q(css=self._bounded_selector('.metadata_edit .wrapper-comp-setting .setting-label'))
for index, setting in enumerate(setting_labels):
if setting.text == label:
return index
return None
def set_field_value_and_save(self, label, value):
"""
Set the field with given label (display name) to the specified value, and presses Save.
"""
index = self.get_setting_entry_index(label)
elem = self.q(css=self._bounded_selector('.metadata_edit div.wrapper-comp-setting input.setting-input'))[index]
# Click in the field, delete the value there.
action = ActionChains(self.browser).click(elem)
for _x in range(0, len(elem.get_attribute('value'))):
action = action.send_keys(Keys.BACKSPACE)
# Send the new text, then Tab to move to the next field (so change event is triggered).
action.send_keys(value).send_keys(Keys.TAB).perform()
click_css(self, 'a.action-save')
...@@ -3,7 +3,7 @@ Container page in Studio ...@@ -3,7 +3,7 @@ Container page in Studio
""" """
from bok_choy.page_object import PageObject from bok_choy.page_object import PageObject
from bok_choy.promise import Promise from bok_choy.promise import Promise, EmptyPromise
from . import BASE_URL from . import BASE_URL
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
...@@ -15,15 +15,24 @@ class ContainerPage(PageObject): ...@@ -15,15 +15,24 @@ class ContainerPage(PageObject):
""" """
Container page in Studio Container page in Studio
""" """
NAME_SELECTOR = 'a.navigation-current'
def __init__(self, browser, unit_locator): def __init__(self, browser, locator):
super(ContainerPage, self).__init__(browser) super(ContainerPage, self).__init__(browser)
self.unit_locator = unit_locator self.locator = locator
@property @property
def url(self): def url(self):
"""URL to the container page for an xblock.""" """URL to the container page for an xblock."""
return "{}/container/{}".format(BASE_URL, self.unit_locator) return "{}/container/{}".format(BASE_URL, self.locator)
@property
def name(self):
titles = self.q(css=self.NAME_SELECTOR).text
if titles:
return titles[0]
else:
return None
def is_browser_on_page(self): def is_browser_on_page(self):
...@@ -91,6 +100,14 @@ class ContainerPage(PageObject): ...@@ -91,6 +100,14 @@ class ContainerPage(PageObject):
# Click the confirmation dialog button # Click the confirmation dialog button
click_css(self, 'a.button.action-primary', 0) click_css(self, 'a.button.action-primary', 0)
def edit(self):
self.q(css='.edit-button').first.click()
EmptyPromise(
lambda: self.q(css='.xblock-studio_view').present,
'Wait for the Studio editor to be present'
).fulfill()
return self
class XBlockWrapper(PageObject): class XBlockWrapper(PageObject):
......
...@@ -5,7 +5,7 @@ from bok_choy.promise import Promise ...@@ -5,7 +5,7 @@ from bok_choy.promise import Promise
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
def click_css(page, css, source_index, require_notification=True): def click_css(page, css, source_index=0, require_notification=True):
""" """
Click the button/link with the given css and index on the specified page (subclass of PageObject). Click the button/link with the given css and index on the specified page (subclass of PageObject).
......
""" """
Acceptance tests for Studio related to the container page. Acceptance tests for Studio related to the container page.
""" """
from ..pages.studio.auto_auth import AutoAuthPage from ..pages.studio.auto_auth import AutoAuthPage
from ..pages.studio.overview import CourseOutlinePage from ..pages.studio.overview import CourseOutlinePage
from ..fixtures.course import CourseFixture, XBlockFixtureDesc from ..fixtures.course import CourseFixture, XBlockFixtureDesc
from .helpers import UniqueCourseTest from .helpers import UniqueCourseTest
from ..pages.studio.component_editor import ComponentEditorView
from unittest import skip
class ContainerBase(UniqueCourseTest): class ContainerBase(UniqueCourseTest):
...@@ -85,13 +89,17 @@ class ContainerBase(UniqueCourseTest): ...@@ -85,13 +89,17 @@ class ContainerBase(UniqueCourseTest):
).install() ).install()
def go_to_container_page(self, make_draft=False): def go_to_container_page(self, make_draft=False):
unit = self.go_to_unit_page(make_draft)
container = unit.components[0].go_to_container()
return container
def go_to_unit_page(self, make_draft=False):
self.outline.visit() self.outline.visit()
subsection = self.outline.section('Test Section').subsection('Test Subsection') subsection = self.outline.section('Test Section').subsection('Test Subsection')
unit = subsection.toggle_expand().unit('Test Unit').go_to() unit = subsection.toggle_expand().unit('Test Unit').go_to()
if make_draft: if make_draft:
unit.edit_draft() unit.edit_draft()
container = unit.components[0].go_to_container() return unit
return container
def verify_ordering(self, container, expected_orderings): def verify_ordering(self, container, expected_orderings):
xblocks = container.xblocks xblocks = container.xblocks
...@@ -131,6 +139,7 @@ class DragAndDropTest(ContainerBase): ...@@ -131,6 +139,7 @@ class DragAndDropTest(ContainerBase):
expected_ordering expected_ordering
) )
@skip("Sporadically drags outside of the Group.")
def test_reorder_in_group(self): def test_reorder_in_group(self):
""" """
Drag Group A Item 2 before Group A Item 1. Drag Group A Item 2 before Group A Item 1.
...@@ -303,3 +312,36 @@ class DeleteComponentTest(ContainerBase): ...@@ -303,3 +312,36 @@ class DeleteComponentTest(ContainerBase):
{self.group_b: [self.group_b_item_1, self.group_b_item_2]}, {self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}] {self.group_empty: []}]
self.delete_and_verify(self.group_a_item_1_action_index, expected_ordering) self.delete_and_verify(self.group_a_item_1_action_index, expected_ordering)
class EditContainerTest(ContainerBase):
"""
Tests of editing a container.
"""
__test__ = True
def modify_display_name_and_verify(self, component):
"""
Helper method for changing a display name.
"""
modified_name = 'modified'
self.assertNotEqual(component.name, modified_name)
component.edit()
component_editor = ComponentEditorView(self.browser, component.locator)
component_editor.set_field_value_and_save('Display Name', modified_name)
self.assertEqual(component.name, modified_name)
def test_edit_container_on_unit_page(self):
"""
Test the "edit" button on a container appearing on the unit page.
"""
unit = self.go_to_unit_page(make_draft=True)
component = unit.components[0]
self.modify_display_name_and_verify(component)
def test_edit_container_on_container_page(self):
"""
Test the "edit" button on a container appearing on the container page.
"""
container = self.go_to_container_page(make_draft=True)
self.modify_display_name_and_verify(container)
<ol class="reorderable-container"> % if can_reorder:
<ol class="reorderable-container">
% endif
% for item in items: % for item in items:
${item['content']} ${item['content']}
% endfor % endfor
</ol> % if can_reorder:
% if not xblock_context['read_only']: </ol>
% endif
% if can_add and not xblock_context['read_only']:
<div class="add-xblock-component new-component-item adding"></div> <div class="add-xblock-component new-component-item adding"></div>
% endif % endif
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