Commit a31a728a by Mushtaq Ali

Add duplicate functionality for unit, sub-section and section - TNL-5797, TNL-5798

parent dc8d020e
......@@ -635,7 +635,7 @@ def _create_item(request):
)
def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_name=None):
def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_name=None, is_child=False):
"""
Duplicate an existing xblock as a child of the supplied parent_usage_key.
"""
......@@ -653,6 +653,10 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_
for field in source_item.fields.values():
if field.scope == Scope.settings and field.is_set_on(source_item):
duplicate_metadata[field.name] = field.read_from(source_item)
if is_child:
display_name = display_name or source_item.display_name or source_item.category
if display_name is not None:
duplicate_metadata['display_name'] = display_name
else:
......@@ -698,7 +702,7 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_
if source_item.has_children and not children_handled:
dest_module.children = dest_module.children or []
for child in source_item.children:
dupe = _duplicate_item(dest_module.location, child, user=user)
dupe = _duplicate_item(dest_module.location, child, user=user, is_child=True)
if dupe not in dest_module.children: # _duplicate_item may add the child for us.
dest_module.children.append(dupe)
store.update_item(dest_module, user.id)
......@@ -944,8 +948,9 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
visibility_state = None
published = modulestore().has_published_version(xblock) if not is_library_block else None
# defining the default value 'True' for delete, drag and add new child actions in xblock_actions for each xblock.
xblock_actions = {'deletable': True, 'draggable': True, 'childAddable': True}
# defining the default value 'True' for delete, duplicate, drag and add new child actions
# in xblock_actions for each xblock.
xblock_actions = {'deletable': True, 'draggable': True, 'childAddable': True, 'duplicable': True}
explanatory_message = None
# is_entrance_exam is inherited metadata.
......
......@@ -484,7 +484,8 @@ class DuplicateHelper(object):
"Duplicated item differs from original"
)
def _check_equality(self, source_usage_key, duplicate_usage_key, parent_usage_key=None, check_asides=False):
def _check_equality(self, source_usage_key, duplicate_usage_key, parent_usage_key=None, check_asides=False,
is_child=False):
"""
Gets source and duplicated items from the modulestore using supplied usage keys.
Then verifies that they represent equivalent items (modulo parents and other
......@@ -523,10 +524,9 @@ class DuplicateHelper(object):
"Parent duplicate should be different from source"
)
# Set the location, display name, and parent to be the same so we can make sure the rest of the
# Set the location and parent to be the same so we can make sure the rest of the
# duplicate is equal.
duplicated_item.location = original_item.location
duplicated_item.display_name = original_item.display_name
duplicated_item.parent = original_item.parent
# Children will also be duplicated, so for the purposes of testing equality, we will set
......@@ -538,11 +538,26 @@ class DuplicateHelper(object):
"Duplicated item differs in number of children"
)
for i in xrange(len(original_item.children)):
if not self._check_equality(original_item.children[i], duplicated_item.children[i]):
if not self._check_equality(original_item.children[i], duplicated_item.children[i], is_child=True):
return False
duplicated_item.children = original_item.children
return self._verify_duplicate_display_name(original_item, duplicated_item, is_child)
return original_item == duplicated_item
def _verify_duplicate_display_name(self, original_item, duplicated_item, is_child=False):
"""
Verifies display name of duplicated item.
"""
if is_child:
if original_item.display_name is None:
return duplicated_item.display_name == original_item.category
return duplicated_item.display_name == original_item.display_name
if original_item.display_name is not None:
return duplicated_item.display_name == "Duplicate of '{display_name}'".format(
display_name=original_item.display_name
)
return duplicated_item.display_name == "Duplicate of {display_name}".format(
display_name=original_item.category
)
def _duplicate_item(self, parent_usage_key, source_usage_key, display_name=None):
"""
......@@ -571,16 +586,20 @@ class TestDuplicateItem(ItemTest, DuplicateHelper):
resp = self.create_xblock(parent_usage_key=self.usage_key, category='chapter')
self.chapter_usage_key = self.response_usage_key(resp)
# create a sequential containing a problem and an html component
# create a sequential
resp = self.create_xblock(parent_usage_key=self.chapter_usage_key, category='sequential')
self.seq_usage_key = self.response_usage_key(resp)
# create a vertical containing a problem and an html component
resp = self.create_xblock(parent_usage_key=self.seq_usage_key, category='vertical')
self.vert_usage_key = self.response_usage_key(resp)
# create problem and an html component
resp = self.create_xblock(parent_usage_key=self.seq_usage_key, category='problem',
resp = self.create_xblock(parent_usage_key=self.vert_usage_key, category='problem',
boilerplate='multiplechoice.yaml')
self.problem_usage_key = self.response_usage_key(resp)
resp = self.create_xblock(parent_usage_key=self.seq_usage_key, category='html')
resp = self.create_xblock(parent_usage_key=self.vert_usage_key, category='html')
self.html_usage_key = self.response_usage_key(resp)
# Create a second sequential just (testing children of children)
......@@ -591,8 +610,9 @@ class TestDuplicateItem(ItemTest, DuplicateHelper):
Tests that a duplicated xblock is identical to the original,
except for location and display name.
"""
self._duplicate_and_verify(self.problem_usage_key, self.seq_usage_key)
self._duplicate_and_verify(self.html_usage_key, self.seq_usage_key)
self._duplicate_and_verify(self.problem_usage_key, self.vert_usage_key)
self._duplicate_and_verify(self.html_usage_key, self.vert_usage_key)
self._duplicate_and_verify(self.vert_usage_key, self.seq_usage_key)
self._duplicate_and_verify(self.seq_usage_key, self.chapter_usage_key)
self._duplicate_and_verify(self.chapter_usage_key, self.usage_key)
......@@ -625,9 +645,10 @@ class TestDuplicateItem(ItemTest, DuplicateHelper):
"duplicated item not ordered after source item"
)
verify_order(self.problem_usage_key, self.seq_usage_key, 0)
verify_order(self.problem_usage_key, self.vert_usage_key, 0)
# 2 because duplicate of problem should be located before.
verify_order(self.html_usage_key, self.seq_usage_key, 2)
verify_order(self.html_usage_key, self.vert_usage_key, 2)
verify_order(self.vert_usage_key, self.seq_usage_key, 0)
verify_order(self.seq_usage_key, self.chapter_usage_key, 0)
# Test duplicating something into a location that is not the parent of the original item.
......@@ -645,12 +666,12 @@ class TestDuplicateItem(ItemTest, DuplicateHelper):
return usage_key
# Display name comes from template.
dupe_usage_key = verify_name(self.problem_usage_key, self.seq_usage_key, "Duplicate of 'Multiple Choice'")
dupe_usage_key = verify_name(self.problem_usage_key, self.vert_usage_key, "Duplicate of 'Multiple Choice'")
# Test dupe of dupe.
verify_name(dupe_usage_key, self.seq_usage_key, "Duplicate of 'Duplicate of 'Multiple Choice''")
verify_name(dupe_usage_key, self.vert_usage_key, "Duplicate of 'Duplicate of 'Multiple Choice''")
# Uses default display_name of 'Text' from HTML component.
verify_name(self.html_usage_key, self.seq_usage_key, "Duplicate of 'Text'")
verify_name(self.html_usage_key, self.vert_usage_key, "Duplicate of 'Text'")
# The sequence does not have a display_name set, so category is shown.
verify_name(self.seq_usage_key, self.chapter_usage_key, "Duplicate of sequential")
......
......@@ -196,6 +196,10 @@ function(Backbone, _, str, ModuleUtils) {
return this.isActionRequired('deletable');
},
isDuplicable: function() {
return this.isActionRequired('duplicable');
},
isDraggable: function() {
return this.isActionRequired('draggable');
},
......
......@@ -400,6 +400,70 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
});
});
describe('Duplicate an xblock', function() {
var duplicateXBlockWithSuccess;
duplicateXBlockWithSuccess = function(xblockLocator, parentLocator, xblockType, xblockIndex) {
getItemHeaders(xblockType).find('.duplicate-button')[xblockIndex].click();
// verify content of request
AjaxHelpers.expectJsonRequest(requests, 'POST', '/xblock/', {
duplicate_source_locator: xblockLocator,
parent_locator: parentLocator
});
// send the response
AjaxHelpers.respondWithJson(requests, {
locator: 'locator-duplicated-xblock'
});
};
it('section can be duplicated', function() {
createCourseOutlinePage(this, mockCourseJSON);
expect(outlinePage.$('.list-sections li.outline-section').length).toEqual(1);
expect(getItemsOfType('section').length, 1);
duplicateXBlockWithSuccess('mock-section', 'mock-course', 'section', 0);
expect(getItemHeaders('section').length, 2);
});
it('subsection can be duplicated', function() {
createCourseOutlinePage(this, mockCourseJSON);
expect(getItemsOfType('subsection').length, 1);
duplicateXBlockWithSuccess('mock-subsection', 'mock-section', 'subsection', 0);
expect(getItemHeaders('subsection').length, 2);
});
it('unit can be duplicated', function() {
createCourseOutlinePage(this, mockCourseJSON);
expandItemsAndVerifyState('subsection');
expect(getItemsOfType('unit').length, 1);
duplicateXBlockWithSuccess('mock-unit', 'mock-subsection', 'unit', 0);
expect(getItemHeaders('unit').length, 2);
});
it('shows a notification when duplicating', function() {
var notificationSpy = EditHelpers.createNotificationSpy();
createCourseOutlinePage(this, mockCourseJSON);
getItemHeaders('section').find('.duplicate-button').first()
.click();
EditHelpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
AjaxHelpers.respondWithJson(requests, {locator: 'locator-duplicated-xblock'});
EditHelpers.verifyNotificationHidden(notificationSpy);
});
it('does not duplicate an xblock upon failure', function() {
var notificationSpy = EditHelpers.createNotificationSpy();
createCourseOutlinePage(this, mockCourseJSON);
expect(getItemHeaders('section').length, 1);
getItemHeaders('section').find('.duplicate-button').first()
.click();
EditHelpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
AjaxHelpers.respondWithError(requests);
expect(getItemHeaders('section').length, 2);
EditHelpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
});
});
describe('Empty course', function() {
it('shows an empty course message initially', function() {
createCourseOutlinePage(this, mockEmptyCourseJSON);
......
......@@ -91,9 +91,28 @@ define(['jquery', 'underscore', 'js/views/xblock_outline', 'common/js/components
}
},
onSectionAdded: function(locator) {
/**
* Perform specific actions for duplicated xblock.
* @param {String} locator The locator of the new duplicated xblock.
* @param {String} xblockType The front-end terminology of the xblock category.
* @param {jquery Element} xblockElement The xblock element to be duplicated.
*/
onChildDuplicated: function(locator, xblockType, xblockElement) {
var scrollOffset = ViewUtils.getScrollOffset(xblockElement);
if (xblockType === 'section') {
this.onSectionAdded(locator, xblockElement, scrollOffset);
} else {
// For all other block types, refresh the view and do the following:
// - show the new block expanded
// - ensure it is scrolled into view
// - make its name editable
this.refresh(this.createNewItemViewState(locator, scrollOffset));
}
},
onSectionAdded: function(locator, xblockElement, scrollOffset) {
var self = this,
initialState = self.createNewItemViewState(locator),
initialState = self.createNewItemViewState(locator, scrollOffset),
sectionInfo, sectionView;
// For new chapters in a non-empty view, add a new child view and render it
// to avoid the expense of refreshing the entire page.
......@@ -108,7 +127,7 @@ define(['jquery', 'underscore', 'js/views/xblock_outline', 'common/js/components
sectionView.initialState = initialState;
sectionView.expandedLocators = self.expandedLocators;
sectionView.render();
self.addChildView(sectionView);
self.addChildView(sectionView, xblockElement);
sectionView.setViewState(initialState);
});
} else {
......
......@@ -209,10 +209,12 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j
buttonPanel = target.closest('.add-xblock-component'),
listPanel = buttonPanel.prev(),
scrollOffset = ViewUtils.getScrollOffset(buttonPanel),
placeholderElement = this.createPlaceholderElement().appendTo(listPanel),
$placeholderEl = $(this.createPlaceholderElement()),
requestData = _.extend(template, {
parent_locator: parentLocator
});
}),
placeholderElement;
placeholderElement = $placeholderEl.appendTo(listPanel);
return $.postJSON(this.getURLRoot() + '/', requestData,
_.bind(this.onNewXBlock, this, placeholderElement, scrollOffset, false))
.fail(function() {
......@@ -226,22 +228,19 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j
// and then onNewXBlock will replace it with a rendering of the xblock. Note that
// for xblocks that can't be replaced inline, the entire parent will be refreshed.
var self = this,
parent = xblockElement.parent();
ViewUtils.runOperationShowingMessage(gettext('Duplicating'),
function() {
var scrollOffset = ViewUtils.getScrollOffset(xblockElement),
placeholderElement = self.createPlaceholderElement().insertAfter(xblockElement),
parentElement = self.findXBlockElement(parent),
requestData = {
duplicate_source_locator: xblockElement.data('locator'),
parent_locator: parentElement.data('locator')
};
return $.postJSON(self.getURLRoot() + '/', requestData,
_.bind(self.onNewXBlock, self, placeholderElement, scrollOffset, true))
.fail(function() {
// Remove the placeholder if the update failed
placeholderElement.remove();
});
parentElement = self.findXBlockElement(xblockElement.parent()),
scrollOffset = ViewUtils.getScrollOffset(xblockElement),
$placeholderEl = $(self.createPlaceholderElement()),
placeholderElement;
placeholderElement = $placeholderEl.insertAfter(xblockElement);
XBlockUtils.duplicateXBlock(xblockElement, parentElement)
.done(function(data) {
self.onNewXBlock(placeholderElement, scrollOffset, true, data);
})
.fail(function() {
// Remove the placeholder if the update failed
placeholderElement.remove();
});
},
......@@ -319,7 +318,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j
updateHtml: function(element, html) {
// Replace the element with the new HTML content, rather than adding
// it as child elements.
this.$el = $(html).replaceAll(element);
this.$el = $(html).replaceAll(element); // safe-lint: disable=javascript-jquery-insertion
}
});
temporaryView = new TemporaryXBlockView({
......
/**
* Provides utilities for views to work with xblocks.
*/
define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_utils', 'js/utils/module'],
function($, _, gettext, ViewUtils, ModuleUtils) {
var addXBlock, deleteXBlock, createUpdateRequestData, updateXBlockField, VisibilityState,
getXBlockVisibilityClass, getXBlockListTypeClass, updateXBlockFields;
define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_utils', 'js/utils/module',
'edx-ui-toolkit/js/utils/string-utils'],
function($, _, gettext, ViewUtils, ModuleUtils, StringUtils) {
'use strict';
var addXBlock, duplicateXBlock, deleteXBlock, createUpdateRequestData, updateXBlockField, VisibilityState,
getXBlockVisibilityClass, getXBlockListTypeClass, updateXBlockFields, getXBlockType;
/**
* Represents the possible visibility states for an xblock:
......@@ -66,6 +68,30 @@ define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_util
};
/**
* Duplicates the specified xblock element in its parent xblock.
* @param {jquery Element} xblockElement The xblock element to be duplicated.
* @param {jquery Element} parentElement Parent element of the xblock element to be duplicated,
* new duplicated xblock would be placed under this xblock.
* @returns {jQuery promise} A promise representing the duplication of the xblock.
*/
duplicateXBlock = function(xblockElement, parentElement) {
return ViewUtils.runOperationShowingMessage(gettext('Duplicating'),
function() {
var duplicationOperation = $.Deferred();
$.postJSON(ModuleUtils.getUpdateUrl(), {
duplicate_source_locator: xblockElement.data('locator'),
parent_locator: parentElement.data('locator')
}, function(data) {
duplicationOperation.resolve(data);
})
.fail(function() {
duplicationOperation.reject();
});
return duplicationOperation.promise();
});
};
/**
* Deletes the specified xblock.
* @param xblockInfo The model for the xblock to be deleted.
* @param xblockType A string representing the type of the xblock to be deleted.
......@@ -87,9 +113,9 @@ define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_util
);
},
messageBody;
xblockType = xblockType || 'component';
messageBody = interpolate(
gettext('Deleting this %(xblock_type)s is permanent and cannot be undone.'),
xblockType = xblockType || 'component'; // eslint-disable-line no-param-reassign
messageBody = StringUtils.interpolate(
gettext('Deleting this {xblock_type} is permanent and cannot be undone.'),
{xblock_type: xblockType},
true
);
......@@ -97,14 +123,14 @@ define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_util
if (xblockInfo.get('is_prereq')) {
messageBody += ' ' + gettext('Any content that has listed this content as a prerequisite will also have access limitations removed.'); // eslint-disable-line max-len
ViewUtils.confirmThenRunOperation(
interpolate(
gettext('Delete this %(xblock_type)s (and prerequisite)?'),
StringUtils.interpolate(
gettext('Delete this {xblock_type} (and prerequisite)?'),
{xblock_type: xblockType},
true
),
messageBody,
interpolate(
gettext('Yes, delete this %(xblock_type)s'),
StringUtils.interpolate(
gettext('Yes, delete this {xblock_type}'),
{xblock_type: xblockType},
true
),
......@@ -112,14 +138,14 @@ define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_util
);
} else {
ViewUtils.confirmThenRunOperation(
interpolate(
gettext('Delete this %(xblock_type)s?'),
StringUtils.interpolate(
gettext('Delete this {xblock_type}?'),
{xblock_type: xblockType},
true
),
messageBody,
interpolate(
gettext('Yes, delete this %(xblock_type)s'),
StringUtils.interpolate(
gettext('Yes, delete this {xblock_type}'),
{xblock_type: xblockType},
true
),
......@@ -217,6 +243,7 @@ define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_util
return {
'VisibilityState': VisibilityState,
'addXBlock': addXBlock,
duplicateXBlock: duplicateXBlock,
'deleteXBlock': deleteXBlock,
'updateXBlockField': updateXBlockField,
'getXBlockVisibilityClass': getXBlockVisibilityClass,
......
......@@ -14,8 +14,10 @@
* - edit_display_name - true if the shown xblock's display name should be in inline edit mode
*/
define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/components/utils/view_utils',
'js/views/utils/xblock_utils', 'js/views/xblock_string_field_editor'],
function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldEditor) {
'js/views/utils/xblock_utils', 'js/views/xblock_string_field_editor',
'edx-ui-toolkit/js/utils/string-utils', 'edx-ui-toolkit/js/utils/html-utils'],
function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldEditor, StringUtils, HtmlUtils) {
'use strict';
var XBlockOutlineView = BaseView.extend({
// takes XBlockInfo as a model
......@@ -68,7 +70,10 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
if (this.parentInfo) {
this.setElement($(html));
} else {
this.$el.html(html);
HtmlUtils.setHtml(
this.$el,
HtmlUtils.HTML(html)
);
}
},
......@@ -83,7 +88,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
defaultNewChildName = null,
isCollapsed = this.shouldRenderChildren() && !this.shouldExpandChildren();
if (childInfo) {
addChildName = interpolate(gettext('New %(component_type)s'), {
addChildName = StringUtils.interpolate(gettext('New {component_type}'), {
component_type: childInfo.display_name
}, true);
defaultNewChildName = childInfo.display_name;
......@@ -126,8 +131,12 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
return this.$('> .outline-content > ol');
},
addChildView: function(childView) {
this.getListElement().append(childView.$el);
addChildView: function(childView, xblockElement) {
if (xblockElement) {
childView.$el.insertAfter(xblockElement);
} else {
this.getListElement().append(childView.$el);
}
},
addNameEditor: function() {
......@@ -186,6 +195,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
addButtonActions: function(element) {
var self = this;
element.find('.delete-button').click(_.bind(this.handleDeleteEvent, this));
element.find('.duplicate-button').click(_.bind(this.handleDuplicateEvent, this));
element.find('.button-new').click(_.bind(this.handleAddEvent, this));
},
......@@ -281,9 +291,9 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
handleDeleteEvent: function(event) {
var self = this,
parentView = this.parentView;
parentView = this.parentView,
xblockType = XBlockViewUtils.getXBlockType(this.model.get('category'), parentView.model, true);
event.preventDefault();
var xblockType = XBlockViewUtils.getXBlockType(this.model.get('category'), parentView.model, true);
XBlockViewUtils.deleteXBlock(this.model, xblockType).done(function() {
if (parentView) {
parentView.onChildDeleted(self, event);
......@@ -291,12 +301,50 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
});
},
/**
* Finds appropriate parent element for an xblock element.
* @param {jquery Element} xblockElement The xblock element to be duplicated.
* @param {String} xblockType The front-end terminology of the xblock category.
* @returns {jquery Element} Appropriate parent element of xblock element.
*/
getParentElement: function(xblockElement, xblockType) {
var xblockMap = {
unit: 'subsection',
subsection: 'section',
section: 'course'
},
parentXblockType = xblockMap[xblockType];
return xblockElement.closest('.outline-' + parentXblockType);
},
/**
* Duplicate event handler.
*/
handleDuplicateEvent: function(event) {
var self = this,
xblockType = XBlockViewUtils.getXBlockType(self.model.get('category'), self.parentView.model, true),
xblockElement = $(event.currentTarget).closest('.outline-item'),
parentElement = self.getParentElement(xblockElement, xblockType);
event.preventDefault();
XBlockViewUtils.duplicateXBlock(xblockElement, parentElement)
.done(function(data) {
if (self.parentView) {
self.parentView.onChildDuplicated(
data.locator,
xblockType,
xblockElement
);
}
});
},
handleAddEvent: function(event) {
var self = this,
target = $(event.currentTarget),
category = target.data('category');
$target = $(event.currentTarget),
category = $target.data('category');
event.preventDefault();
XBlockViewUtils.addXBlock(target).done(function(locator) {
XBlockViewUtils.addXBlock($target).done(function(locator) {
self.onChildAdded(locator, category, event);
});
}
......
......@@ -112,6 +112,14 @@ if (is_proctored_exam) {
</a>
</li>
<% } %>
<% if (xblockInfo.isDuplicable()) { %>
<li class="action-item action-duplicate">
<a href="#" data-tooltip="<%- gettext('Duplicate') %>" class="duplicate-button action-button">
<span class="icon fa fa-copy" aria-hidden="true"></span>
<span class="sr action-button-text"><%- gettext('Duplicate') %></span>
</a>
</li>
<% } %>
<% if (xblockInfo.isDeletable()) { %>
<li class="action-item action-delete">
<a href="#" data-tooltip="<%- gettext('Delete') %>" class="delete-button action-button">
......
......@@ -38,7 +38,7 @@
<section class="content">
<article class="content-primary" role="main">
<div class="wrapper-dnd">
<article class="outline" data-locator="mock-course" data-course-key="slashes:MockCourse">
<article class="outline outline-course" data-locator="mock-course" data-course-key="slashes:MockCourse">
<div class="no-content add-xblock-component">
<p>You haven't added any content to this course yet.
<a href="#" class="button button-new" data-category="chapter" data-parent="mock-course" data-default-name="Section" title="Click to add a new section">
......
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