diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6b0af2d5be86..99c728dcfa9a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,8 @@ Blades: Video player start-end time range is now shown even before Play is clicked. Video player VCR time shows correct non-zero total time for YouTube videos even before Play is clicked. BLD-529. +Studio: Add ability to duplicate components on the unit page. + Blades: Adds CookieStorage utility for video player that provides convenient way to work with cookies. diff --git a/cms/djangoapps/contentstore/features/component.feature b/cms/djangoapps/contentstore/features/component.feature index 59238e3986c0..5df49e59fd8f 100644 --- a/cms/djangoapps/contentstore/features/component.feature +++ b/cms/djangoapps/contentstore/features/component.feature @@ -101,3 +101,14 @@ Feature: CMS.Component Adding And I add a "Blank Advanced Problem" "Advanced Problem" component And I delete all components Then I see no components + + Scenario: I can duplicate a component + Given I am in Studio editing a new unit + And I add a "Blank Common Problem" "Problem" component + And I add a "Multiple Choice" "Problem" component + And I duplicate the first component + Then I see a Problem component with display name "Duplicate of 'Blank Common Problem'" in position "1" + And I reload the page + Then I see a Problem component with display name "Blank Common Problem" in position "0" + And I see a Problem component with display name "Duplicate of 'Blank Common Problem'" in position "1" + And I see a Problem component with display name "Multiple Choice" in position "2" diff --git a/cms/djangoapps/contentstore/features/component.py b/cms/djangoapps/contentstore/features/component.py index 387510b5ea0e..cfe4053b2fe7 100644 --- a/cms/djangoapps/contentstore/features/component.py +++ b/cms/djangoapps/contentstore/features/component.py @@ -132,3 +132,25 @@ def delete_one_component(step): def edit_and_save_component(step): world.css_click('.edit-button') world.css_click('.save-button') + + +@step(u'I duplicate the (first|second|third) component$') +def duplicated_component(step, ordinal): + ord_map = { + "first": 0, + "second": 1, + "third": 2, + } + index = ord_map[ordinal] + duplicate_btn_css = 'a.duplicate-button' + world.css_click(duplicate_btn_css, int(index)) + + +@step(u'I see a Problem component with display name "([^"]*)" in position "([^"]*)"$') +def see_component_in_position(step, display_name, index): + component_css = 'section.xmodule_CapaModule' + + def find_problem(_driver): + return world.css_text(component_css, int(index)).startswith(display_name.upper()) + + world.wait_for(find_problem, timeout_msg='Did not find the duplicated problem') diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py index b41a3717507b..238660f5100d 100644 --- a/cms/djangoapps/contentstore/tests/test_item.py +++ b/cms/djangoapps/contentstore/tests/test_item.py @@ -288,8 +288,8 @@ def verify_name(source_locator, parent_locator, expected_name, display_name=None # Uses default display_name of 'Text' from HTML component. verify_name(self.html_locator, self.seq_locator, "Duplicate of 'Text'") - # The sequence does not have a display_name set, so None gets included as the string 'None'. - verify_name(self.seq_locator, self.chapter_locator, "Duplicate of 'None'") + # The sequence does not have a display_name set, so category is shown. + verify_name(self.seq_locator, self.chapter_locator, "Duplicate of sequential") # Now send a custom display name for the duplicate. verify_name(self.seq_locator, self.chapter_locator, "customized name", display_name="customized name") diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 36779d996491..116fa33894f1 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -321,7 +321,10 @@ def _duplicate_item(parent_location, duplicate_source_location, display_name=Non if display_name is not None: duplicate_metadata['display_name'] = display_name else: - duplicate_metadata['display_name'] = _("Duplicate of '{0}'").format(source_item.display_name) + if source_item.display_name is None: + duplicate_metadata['display_name'] = _("Duplicate of {0}").format(source_item.category) + else: + duplicate_metadata['display_name'] = _("Duplicate of '{0}'").format(source_item.display_name) get_modulestore(category).create_and_save_xmodule( dest_location, diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index 4e3cf838c171..2cbc85d7c1ce 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -214,6 +214,8 @@ define([ "js/spec/views/baseview_spec", "js/spec/views/paging_spec", + "js/spec/views/unit_spec" + # these tests are run separate in the cms-squire suite, due to process # isolation issues with Squire.js # "coffee/spec/views/assets_spec" diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee index 729a17615e18..a9628b628fe2 100644 --- a/cms/static/coffee/src/views/module_edit.coffee +++ b/cms/static/coffee/src/views/module_edit.coffee @@ -1,6 +1,6 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1", "js/views/feedback_notification", "js/views/metadata", "js/collections/metadata" - "js/utils/modal", "jquery.inputnumber", "xmodule"], + "js/utils/modal", "jquery.inputnumber", "xmodule", "coffee/src/main"], (Backbone, $, _, gettext, XBlock, NotificationView, MetadataView, MetadataCollection, ModalUtils) -> class ModuleEdit extends Backbone.View tagName: 'li' @@ -62,7 +62,7 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1", changedMetadata: -> return _.extend(@metadataEditor.getModifiedMetadataValues(), @customMetadata()) - createItem: (parent, payload) -> + createItem: (parent, payload, callback=->) -> payload.parent_locator = parent $.postJSON( @model.urlRoot @@ -71,7 +71,7 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1", @model.set(id: data.locator) @$el.data('locator', data.locator) @render() - ) + ).success(callback) render: -> if @model.id diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee index c4ff25c309ca..8b2bc885d08b 100644 --- a/cms/static/coffee/src/views/unit.coffee +++ b/cms/static/coffee/src/views/unit.coffee @@ -13,6 +13,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone", 'click .create-draft': 'createDraft' 'click .publish-draft': 'publishDraft' 'change .visibility-select': 'setVisibility' + "click .component-actions .duplicate-button": 'duplicateComponent' initialize: => @visibilityView = new UnitEditView.Visibility( @@ -86,7 +87,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone", @$newComponentItem.removeClass('adding') @$newComponentItem.find('.rendered-component').remove() - saveNewComponent: (event) => + createComponent: (event, data, notification_message, analytics_message, success_callback) => event.preventDefault() editor = new ModuleEditView( @@ -94,20 +95,54 @@ define ["jquery", "jquery.ui", "gettext", "backbone", model: new ModuleModel() ) - @$newComponentItem.before(editor.$el) + notification = new NotificationView.Mini + title: notification_message + + notification.show() + + callback = -> + notification.hide() + success_callback() + analytics.track analytics_message, + course: course_location_analytics + unit_id: unit_location_analytics + type: editor.$el.data('locator') editor.createItem( @$el.data('locator'), - $(event.currentTarget).data() + data, + callback ) - analytics.track "Added a Component", - course: course_location_analytics - unit_id: unit_location_analytics - type: $(event.currentTarget).data('location') + return editor + saveNewComponent: (event) => + success_callback = => + @$newComponentItem.before(editor.$el) + editor = @createComponent( + event, $(event.currentTarget).data(), + gettext('Adding…'), + "Creating new component", + success_callback + ) @closeNewComponent(event) + duplicateComponent: (event) => + $component = $(event.currentTarget).parents('.component') + source_locator = $component.data('locator') + success_callback = -> + $component.after(editor.$el) + $('html, body').animate({ + scrollTop: editor.$el.offset().top + }, 500) + editor = @createComponent( + event, + {duplicate_source_locator: source_locator}, + gettext('Duplicating…') + "Duplicating " + source_locator, + success_callback + ) + components: => @$('.component').map((idx, el) -> $(el).data('locator')).get() wait: (value) => diff --git a/cms/static/js/spec/views/unit_spec.js b/cms/static/js/spec/views/unit_spec.js new file mode 100644 index 000000000000..189b935edaa8 --- /dev/null +++ b/cms/static/js/spec/views/unit_spec.js @@ -0,0 +1,164 @@ +define(["coffee/src/views/unit", "js/models/module_info", "js/spec/create_sinon", "js/views/feedback_notification", + "jasmine-stealth"], + function (UnitEditView, ModuleModel, create_sinon, NotificationView) { + var verifyJSON = function (requests, json) { + var request = requests[requests.length - 1]; + expect(request.url).toEqual("/xblock"); + expect(request.method).toEqual("POST"); + expect(request.requestBody).toEqual(json); + }; + + var verifyComponents = function (unit, locators) { + var components = unit.$(".component"); + expect(components.length).toBe(locators.length); + for (var i=0; i < locators.length; i++) { + expect($(components[i]).data('locator')).toBe(locators[i]); + } + }; + + var verifyNotification = function (notificationSpy, text, requests) { + expect(notificationSpy.constructor).toHaveBeenCalled(); + expect(notificationSpy.show).toHaveBeenCalled(); + expect(notificationSpy.hide).not.toHaveBeenCalled(); + var options = notificationSpy.constructor.mostRecentCall.args[0]; + expect(options.title).toMatch(text); + create_sinon.respondWithJson(requests, {"locator": "new_item"}); + expect(notificationSpy.hide).toHaveBeenCalled(); + }; + + describe('duplicateComponent ', function () { + var duplicateFixture = + '
'; + + var unit; + var clickDuplicate = function (index) { + unit.$(".duplicate-button")[index].click(); + }; + beforeEach(function () { + setFixtures(duplicateFixture); + unit = new UnitEditView({ + el: $('.main-wrapper'), + model: new ModuleModel({ + id: 'unit_locator', + state: 'draft' + }) + }); + }); + + it('sends the correct JSON to the server', function () { + var requests = create_sinon.requests(this); + clickDuplicate(0); + verifyJSON(requests, '{"duplicate_source_locator":"loc_1","parent_locator":"unit_locator"}'); + }); + + it('inserts duplicated component immediately after source upon success', function () { + var requests = create_sinon.requests(this); + clickDuplicate(0); + create_sinon.respondWithJson(requests, {"locator": "duplicated_item"}); + verifyComponents(unit, ['loc_1', 'duplicated_item', 'loc_2']); + }); + + it('inserts duplicated component at end if source at end', function () { + var requests = create_sinon.requests(this); + clickDuplicate(1); + create_sinon.respondWithJson(requests, {"locator": "duplicated_item"}); + verifyComponents(unit, ['loc_1', 'loc_2', 'duplicated_item']); + }); + + it('shows a notification while duplicating', function () { + var notificationSpy = spyOnConstructor(NotificationView, "Mini", ["show", "hide"]); + notificationSpy.show.andReturn(notificationSpy); + + var requests = create_sinon.requests(this); + clickDuplicate(0); + verifyNotification(notificationSpy, /Duplicating/, requests); + }); + + it('does not insert duplicated component upon failure', function () { + var server = create_sinon.server(500, this); + clickDuplicate(0); + server.respond(); + verifyComponents(unit, ['loc_1', 'loc_2']); + }); + }); + describe('saveNewComponent ', function () { + var newComponentFixture = + ''; + + var unit; + var clickNewComponent = function () { + unit.$(".new-component .new-component-type a.single-template").click(); + }; + beforeEach(function () { + setFixtures(newComponentFixture); + unit = new UnitEditView({ + el: $('.main-wrapper'), + model: new ModuleModel({ + id: 'unit_locator', + state: 'draft' + }) + }); + }); + it('sends the correct JSON to the server', function () { + var requests = create_sinon.requests(this); + clickNewComponent(); + verifyJSON(requests, '{"category":"discussion","type":"discussion","parent_locator":"unit_locator"}'); + }); + + it('inserts new component at end', function () { + var requests = create_sinon.requests(this); + clickNewComponent(); + create_sinon.respondWithJson(requests, {"locator": "new_item"}); + verifyComponents(unit, ['loc_1', 'loc_2', 'new_item']); + }); + + it('shows a notification while creating', function () { + var notificationSpy = spyOnConstructor(NotificationView, "Mini", ["show", "hide"]); + notificationSpy.show.andReturn(notificationSpy); + var requests = create_sinon.requests(this); + clickNewComponent(); + verifyNotification(notificationSpy, /Adding/, requests); + }); + + it('does not insert duplicated component upon failure', function () { + var server = create_sinon.server(500, this); + clickNewComponent(); + server.respond(); + verifyComponents(unit, ['loc_1', 'loc_2']); + }); + }); + } +); diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index 219b9bf18749..553c6875264c 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -689,7 +689,8 @@ hr.divide { } .edit-button.standard, -.delete-button.standard { +.delete-button.standard, +.duplicate-button.standard { @extend %t-action4; @include white-button; float: left; @@ -698,7 +699,8 @@ hr.divide { font-weight: 400; .edit-icon, - .delete-icon { + .delete-icon, + .duplicate-icon{ margin-right: 4px; } } diff --git a/cms/static/sass/assets/_graphics.scss b/cms/static/sass/assets/_graphics.scss index be047d5215d1..3bd927841fc9 100644 --- a/cms/static/sass/assets/_graphics.scss +++ b/cms/static/sass/assets/_graphics.scss @@ -105,6 +105,13 @@ } } +.duplicate-icon { + display: inline-block; + width: 12px; + height: 12px; + margin-right: 2px; +} + .visibility-toggle { .toggle-icon { display: inline-block; diff --git a/cms/static/sass/views/_static-pages.scss b/cms/static/sass/views/_static-pages.scss index 5b441122389e..d08329ff8edf 100644 --- a/cms/static/sass/views/_static-pages.scss +++ b/cms/static/sass/views/_static-pages.scss @@ -163,6 +163,10 @@ margin-right: 12px; } } + + .duplicate-button.standard { + display: none; + } } .edit-static-page { diff --git a/cms/templates/component.html b/cms/templates/component.html index cd8da36d00da..46147ba27cfb 100644 --- a/cms/templates/component.html +++ b/cms/templates/component.html @@ -29,6 +29,7 @@