diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 3a76963d55..df448141fd 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -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. diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index 4455cf7dad..93637793b9 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -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") diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js index 41191f7577..e3fd1c706c 100644 --- a/cms/static/js/models/xblock_info.js +++ b/cms/static/js/models/xblock_info.js @@ -196,6 +196,10 @@ function(Backbone, _, str, ModuleUtils) { return this.isActionRequired('deletable'); }, + isDuplicable: function() { + return this.isActionRequired('duplicable'); + }, + isDraggable: function() { return this.isActionRequired('draggable'); }, diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js index 8cdb4a96b1..ff49fd0060 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -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); diff --git a/cms/static/js/views/course_outline.js b/cms/static/js/views/course_outline.js index 82514a2ef7..d15609a7f6 100644 --- a/cms/static/js/views/course_outline.js +++ b/cms/static/js/views/course_outline.js @@ -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 { diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 2e223638ed..73ee99d834 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -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({ diff --git a/cms/static/js/views/utils/xblock_utils.js b/cms/static/js/views/utils/xblock_utils.js index 127da67b38..38233f8cef 100644 --- a/cms/static/js/views/utils/xblock_utils.js +++ b/cms/static/js/views/utils/xblock_utils.js @@ -1,10 +1,12 @@ /** * 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: @@ -65,6 +67,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. @@ -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, diff --git a/cms/static/js/views/xblock_outline.js b/cms/static/js/views/xblock_outline.js index 6e58b1c67d..2d6c88afd0 100644 --- a/cms/static/js/views/xblock_outline.js +++ b/cms/static/js/views/xblock_outline.js @@ -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); }); } diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore index 2b9fbb8d40..383176314c 100644 --- a/cms/templates/js/course-outline.underscore +++ b/cms/templates/js/course-outline.underscore @@ -112,6 +112,14 @@ if (is_proctored_exam) { <% } %> + <% if (xblockInfo.isDuplicable()) { %> +