Front-end work for duplicating components on the unit page.
STUD-1186
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 "0" 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"
|
||||
|
||||
@@ -132,3 +132,19 @@ 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 "([^"]*)" component$')
|
||||
def duplicated_component(step, index):
|
||||
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')
|
||||
|
||||
@@ -288,8 +288,8 @@ class TestDuplicateItem(ItemTest):
|
||||
# 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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, message, success_callback) =>
|
||||
event.preventDefault()
|
||||
|
||||
editor = new ModuleEditView(
|
||||
@@ -94,20 +95,52 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
|
||||
model: new ModuleModel()
|
||||
)
|
||||
|
||||
@$newComponentItem.before(editor.$el)
|
||||
notification = new NotificationView.Mini
|
||||
title: gettext(message) + '…'
|
||||
|
||||
notification.show()
|
||||
|
||||
callback = ->
|
||||
notification.hide()
|
||||
success_callback()
|
||||
analytics.track 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(),
|
||||
"Adding",
|
||||
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},
|
||||
"Duplicating",
|
||||
success_callback
|
||||
)
|
||||
|
||||
components: => @$('.component').map((idx, el) -> $(el).data('locator')).get()
|
||||
|
||||
wait: (value) =>
|
||||
|
||||
166
cms/static/js/spec/views/unit_spec.js
Normal file
166
cms/static/js/spec/views/unit_spec.js
Normal file
@@ -0,0 +1,166 @@
|
||||
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 unit;
|
||||
var clickDuplicate = function (index) {
|
||||
unit.$(".duplicate-button")[index].click();
|
||||
};
|
||||
beforeEach(function () {
|
||||
setFixtures(
|
||||
'<div class="main-wrapper edit-state-draft" data-locator="unit_locator"> \
|
||||
<ol class="components"> \
|
||||
<li class="component" data-locator="loc_1"> \
|
||||
<div class="wrapper wrapper-component-editor"> \
|
||||
</div> \
|
||||
<div class="component-actions"> \
|
||||
<a href="#" class="duplicate-button standard"><span class="duplicate-icon icon-copy"></span>Duplicate</a> \
|
||||
</div> \
|
||||
</li> \
|
||||
<li class="component" data-locator="loc_2"> \
|
||||
<div class="wrapper wrapper-component-editor"> \
|
||||
</div> \
|
||||
<div class="component-actions"> \
|
||||
<a href="#" class="duplicate-button standard"><span class="duplicate-icon icon-copy"></span>Duplicate</a> \
|
||||
</div> \
|
||||
</li> \
|
||||
</ol> \
|
||||
'
|
||||
);
|
||||
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 and shows notification', 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 last duplicated', 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 unit;
|
||||
var clickNewComponent = function () {
|
||||
unit.$(".new-component .new-component-type a.single-template").click();
|
||||
};
|
||||
beforeEach(function () {
|
||||
setFixtures(
|
||||
'<div class="main-wrapper edit-state-draft" data-locator="unit_locator"> \
|
||||
<ol class="components"> \
|
||||
<li class="component" data-locator="loc_1"> \
|
||||
<div class="wrapper wrapper-component-editor"> \
|
||||
</div> \
|
||||
</li> \
|
||||
<li class="component" data-locator="loc_2"> \
|
||||
<div class="wrapper wrapper-component-editor"> \
|
||||
</div> \
|
||||
</li> \
|
||||
<li class="new-component-item adding"> \
|
||||
<div class="new-component"> \
|
||||
<ul class="new-component-type"> \
|
||||
<li> \
|
||||
<a href="#" class="single-template" data-type="discussion" data-category="discussion"/> \
|
||||
</li> \
|
||||
</ul> \
|
||||
</div> \
|
||||
</li> \
|
||||
</ol> \
|
||||
'
|
||||
);
|
||||
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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.duplicate-icon {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.visibility-toggle {
|
||||
.toggle-icon {
|
||||
display: inline-block;
|
||||
|
||||
@@ -163,6 +163,10 @@
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.duplicate-button.standard {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-static-page {
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
<div class="component-actions">
|
||||
<a href="#" class="edit-button standard"><span class="edit-icon"></span>${_("Edit")}</a>
|
||||
<a href="#" class="duplicate-button standard"><span class="duplicate-icon icon-copy"></span>${_("Duplicate")}</a>
|
||||
<a href="#" class="delete-button standard"><span class="delete-icon"></span>${_("Delete")}</a>
|
||||
</div>
|
||||
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
|
||||
|
||||
Reference in New Issue
Block a user