Allow creation of components on container page

This commit implements STUD-1490, allowing creation of components
on the container page. It also enables the delete and duplicate
buttons now that new content can be created that would benefit.

Note that it also creates shared functionality for adding components,
and refactors the unit page to use it too.
This commit is contained in:
Andy Armstrong
2014-05-02 14:53:31 -04:00
parent 5752312bbb
commit 541d20ef83
70 changed files with 2907 additions and 1931 deletions

View File

@@ -0,0 +1,5 @@
define(["backbone", "js/models/component_template"], function(Backbone, ComponentTemplate) {
return Backbone.Collection.extend({
model : ComponentTemplate
});
});

View File

@@ -0,0 +1,31 @@
/**
* Simple model for adding a component of a given type (for example, "video" or "html").
*/
define(["backbone"], function (Backbone) {
return Backbone.Model.extend({
defaults: {
type: "",
// Each entry in the template array is an Object with the following keys:
// display_name
// category (may or may not match "type")
// boilerplate_name (may be null)
// is_common (only used for problems)
templates: []
},
parse: function (response) {
this.type = response.type;
this.templates = response.templates;
// Sort the templates.
this.templates.sort(function (a, b) {
// The entry without a boilerplate always goes first
if (!a.boilerplate_name || (a.display_name < b.display_name)) {
return -1;
}
else {
return (a.display_name > b.display_name) ? 1 : 0;
}
});
}
});
});

View File

@@ -1,5 +1,6 @@
define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_binding", "sinon"],
function ($, _, BaseView, IframeBinding, sinon) {
define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_binding", "sinon",
"js/spec_helpers/edit_helpers"],
function ($, _, BaseView, IframeBinding, sinon, view_helpers) {
describe("BaseView", function() {
var baseViewPrototype;
@@ -79,8 +80,7 @@ define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_bin
describe("disabled element while running", function() {
it("adds 'is-disabled' class to element while action is running and removes it after", function() {
var viewWithLink,
link,
var link,
deferred = new $.Deferred(),
promise = deferred.promise(),
view = new BaseView();
@@ -89,11 +89,37 @@ define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_bin
link = $("#link");
expect(link).not.toHaveClass("is-disabled");
view.disableElementWhileRunning(link, function(){return promise});
view.disableElementWhileRunning(link, function() { return promise; });
expect(link).toHaveClass("is-disabled");
deferred.resolve();
expect(link).not.toHaveClass("is-disabled");
});
});
describe("progress notification", function() {
it("shows progress notification and removes it upon success", function() {
var testMessage = "Testing...",
deferred = new $.Deferred(),
promise = deferred.promise(),
view = new BaseView(),
notificationSpy = view_helpers.createNotificationSpy();
view.runOperationShowingMessage(testMessage, function() { return promise; });
view_helpers.verifyNotificationShowing(notificationSpy, /Testing/);
deferred.resolve();
view_helpers.verifyNotificationHidden(notificationSpy);
});
it("shows progress notification and leaves it showing upon failure", function() {
var testMessage = "Testing...",
deferred = new $.Deferred(),
promise = deferred.promise(),
view = new BaseView(),
notificationSpy = view_helpers.createNotificationSpy();
view.runOperationShowingMessage(testMessage, function() { return promise; });
view_helpers.verifyNotificationShowing(notificationSpy, /Testing/);
deferred.fail();
view_helpers.verifyNotificationShowing(notificationSpy, /Testing/);
});
});
});
});

View File

@@ -1,7 +1,7 @@
define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers",
"js/views/container", "js/models/xblock_info", "js/views/feedback_notification", "jquery.simulate",
"js/views/container", "js/models/xblock_info", "jquery.simulate",
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function ($, create_sinon, view_helpers, ContainerView, XBlockInfo, Notification) {
function ($, create_sinon, view_helpers, ContainerView, XBlockInfo) {
describe("Container View", function () {
@@ -9,7 +9,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
var model, containerView, mockContainerHTML, respondWithMockXBlockFragment, init, getComponent,
getDragHandle, dragComponentVertically, dragComponentAbove,
verifyRequest, verifyNumReorderCalls, respondToRequest,
verifyRequest, verifyNumReorderCalls, respondToRequest, notificationSpy,
rootLocator = 'testCourse/branch/draft/split_test/splitFFF',
containerTestUrl = '/xblock/' + rootLocator,
@@ -35,7 +35,8 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
beforeEach(function () {
view_helpers.installViewTemplates();
appendSetFixtures('<div class="wrapper-xblock level-page" data-locator="' + rootLocator + '"></div>');
appendSetFixtures('<div class="wrapper-xblock level-page studio-xblock-wrapper" data-locator="' + rootLocator + '"></div>');
notificationSpy = view_helpers.createNotificationSpy();
model = new XBlockInfo({
id: rootLocator,
display_name: 'Test AB Test',
@@ -63,16 +64,29 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
});
$('body').append(containerView.$el);
// Give the whole container enough height to contain everything.
$('.xblock[data-locator=locator-container]').css('height', 2000);
// Give the groups enough height to contain their child vertical elements.
$('.is-draggable[data-locator=locator-group-A]').css('height', 800);
$('.is-draggable[data-locator=locator-group-B]').css('height', 800);
// Give the leaf elements some height to mimic actual components. Otherwise
// drag and drop fails as the elements on bunched on top of each other.
$('.level-element').css('height', 200);
return requests;
};
getComponent = function(locator) {
return containerView.$('[data-locator="' + locator + '"]');
return containerView.$('.studio-xblock-wrapper[data-locator="' + locator + '"]');
};
getDragHandle = function(locator) {
var component = getComponent(locator);
return component.prev();
return $(component.find('.drag-handle')[0]);
};
dragComponentVertically = function (locator, dy) {
@@ -166,31 +180,17 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
});
describe("Shows a saving message", function () {
var savingSpies;
beforeEach(function () {
savingSpies = spyOnConstructor(Notification, "Mini",
["show", "hide"]);
savingSpies.show.andReturn(savingSpies);
});
it('hides saving message upon success', function () {
var requests, savingOptions;
requests = init(this);
// Drag the first component in Group B to the first group.
dragComponentAbove(groupBComponent1, groupAComponent1);
expect(savingSpies.constructor).toHaveBeenCalled();
expect(savingSpies.show).toHaveBeenCalled();
expect(savingSpies.hide).not.toHaveBeenCalled();
savingOptions = savingSpies.constructor.mostRecentCall.args[0];
expect(savingOptions.title).toMatch(/Saving/);
view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
respondToRequest(requests, 0, 200);
expect(savingSpies.hide).not.toHaveBeenCalled();
view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
respondToRequest(requests, 1, 200);
expect(savingSpies.hide).toHaveBeenCalled();
view_helpers.verifyNotificationHidden(notificationSpy);
});
it('does not hide saving message if failure', function () {
@@ -198,13 +198,9 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
// Drag the first component in Group B to the first group.
dragComponentAbove(groupBComponent1, groupAComponent1);
expect(savingSpies.constructor).toHaveBeenCalled();
expect(savingSpies.show).toHaveBeenCalled();
expect(savingSpies.hide).not.toHaveBeenCalled();
view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
respondToRequest(requests, 0, 500);
expect(savingSpies.hide).not.toHaveBeenCalled();
view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
// Since the first reorder call failed, the removal will not be called.
verifyNumReorderCalls(requests, 1);

View File

@@ -1,13 +1,13 @@
define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers",
"js/views/feedback_notification", "js/views/feedback_prompt",
"js/views/pages/container", "js/models/xblock_info"],
function ($, create_sinon, edit_helpers, Notification, Prompt, ContainerPage, XBlockInfo) {
define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers",
"js/views/feedback_prompt", "js/views/pages/container", "js/models/xblock_info"],
function ($, _, create_sinon, edit_helpers, Prompt, ContainerPage, XBlockInfo) {
describe("ContainerPage", function() {
var lastRequest, renderContainerPage, expectComponents, respondWithHtml,
model, containerPage, requests,
mockContainerPage = readFixtures('mock/mock-container-page.underscore'),
ABTestFixture = readFixtures('mock/mock-container-xblock.underscore');
mockContainerXBlockHtml = readFixtures('mock/mock-container-xblock.underscore'),
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore');
beforeEach(function () {
edit_helpers.installEditTemplates();
@@ -20,6 +20,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
});
containerPage = new ContainerPage({
model: model,
templates: edit_helpers.mockComponentTemplates,
el: $('#content')
});
});
@@ -43,7 +44,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
expectComponents = function (container, locators) {
// verify expected components (in expected order) by their locators
var components = $(container).find('[data-locator]');
var components = $(container).find('.studio-xblock-wrapper');
expect(components.length).toBe(locators.length);
_.each(locators, function(locator, locator_index) {
expect($(components[locator_index]).data('locator')).toBe(locator);
@@ -51,8 +52,6 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
};
describe("Basic display", function() {
var mockContainerXBlockHtml = readFixtures('mock/mock-container-xblock.underscore');
it('can render itself', function() {
renderContainerPage(mockContainerXBlockHtml, this);
expect(containerPage.$el.select('.xblock-header')).toBeTruthy();
@@ -69,9 +68,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
});
describe("Editing an xblock", function() {
var mockContainerXBlockHtml,
mockXBlockEditorHtml,
newDisplayName = 'New Display Name';
var newDisplayName = 'New Display Name';
beforeEach(function () {
edit_helpers.installMockXBlock({
@@ -87,9 +84,6 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
edit_helpers.cancelModalIfShowing();
});
mockContainerXBlockHtml = readFixtures('mock/mock-container-xblock.underscore');
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore');
it('can show an edit modal for a child xblock', function() {
var editButtons;
renderContainerPage(mockContainerXBlockHtml, this);
@@ -110,8 +104,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
});
describe("Editing an xmodule", function() {
var mockContainerXBlockHtml,
mockXModuleEditor,
var mockXModuleEditor = readFixtures('mock/mock-xmodule-editor.underscore'),
newDisplayName = 'New Display Name';
beforeEach(function () {
@@ -128,9 +121,6 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
edit_helpers.cancelModalIfShowing();
});
mockContainerXBlockHtml = readFixtures('mock/mock-container-xblock.underscore');
mockXModuleEditor = readFixtures('mock/mock-xmodule-editor.underscore');
it('can save changes to settings', function() {
var editButtons, modal, mockUpdatedXBlockHtml;
mockUpdatedXBlockHtml = readFixtures('mock/mock-updated-xblock.underscore');
@@ -165,43 +155,32 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
});
describe("Empty container", function() {
var mockContainerXBlockHtml = readFixtures('mock/mock-empty-container-xblock.underscore');
var mockEmptyContainerXBlockHtml = readFixtures('mock/mock-empty-container-xblock.underscore');
it('shows the "no children" message', function() {
renderContainerPage(mockContainerXBlockHtml, this);
renderContainerPage(mockEmptyContainerXBlockHtml, this);
expect(containerPage.$('.no-container-content')).not.toHaveClass('is-hidden');
expect(containerPage.$('.wrapper-xblock')).toHaveClass('is-hidden');
});
});
describe("xblock operations", function() {
var getGroupElement, expectNumComponents, expectNotificationToBeShown,
var getGroupElement, expectNumComponents,
NUM_GROUPS = 2, NUM_COMPONENTS_PER_GROUP = 3, GROUP_TO_TEST = "A",
notificationSpies,
allComponentsInGroup = _.map(
_.range(NUM_COMPONENTS_PER_GROUP),
function(index) { return 'locator-component-' + GROUP_TO_TEST + (index + 1); }
);
beforeEach(function () {
notificationSpies = spyOnConstructor(Notification, "Mini", ["show", "hide"]);
notificationSpies.show.andReturn(notificationSpies);
});
getGroupElement = function() {
return containerPage.$("[data-locator='locator-group-" + GROUP_TO_TEST + "']");
};
expectNumComponents = function(numComponents) {
expect(containerPage.$('.wrapper-xblock.level-element').length).toBe(
numComponents * NUM_GROUPS
);
};
expectNotificationToBeShown = function(expectedTitle) {
expect(notificationSpies.constructor).toHaveBeenCalled();
expect(notificationSpies.show).toHaveBeenCalled();
expect(notificationSpies.hide).not.toHaveBeenCalled();
expect(notificationSpies.constructor.mostRecentCall.args[0].title).toMatch(expectedTitle);
};
describe("Deleting an xblock", function() {
var clickDelete, deleteComponent, deleteComponentWithSuccess,
@@ -212,7 +191,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
promptSpies.show.andReturn(this.promptSpies);
});
clickDelete = function(componentIndex) {
clickDelete = function(componentIndex, clickNo) {
// find all delete buttons for the given group
var deleteButtons = getGroupElement().find(".delete-button");
@@ -226,21 +205,18 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
// no components should be deleted yet
expectNumComponents(NUM_COMPONENTS_PER_GROUP);
// click 'Yes' or 'No' on delete confirmation
if (clickNo) {
promptSpies.constructor.mostRecentCall.args[0].actions.secondary.click(promptSpies);
} else {
promptSpies.constructor.mostRecentCall.args[0].actions.primary.click(promptSpies);
}
};
deleteComponent = function(componentIndex, responseCode) {
// click delete button for given component
deleteComponent = function(componentIndex) {
clickDelete(componentIndex);
// click 'Yes' on delete confirmation
promptSpies.constructor.mostRecentCall.args[0].actions.primary.click(promptSpies);
// expect 'deleting' notification to be shown
expectNotificationToBeShown(/Deleting/);
// respond to request with given response code
lastRequest().respond(responseCode, {}, "");
create_sinon.respondWithJson(requests, {});
// expect request URL to contain given component's id
expect(lastRequest().url).toMatch(
@@ -249,12 +225,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
};
deleteComponentWithSuccess = function(componentIndex) {
// delete component with an 'OK' response code
deleteComponent(componentIndex, 200);
// expect 'deleting' notification to be hidden
expect(notificationSpies.hide).toHaveBeenCalled();
deleteComponent(componentIndex);
// verify the new list of components within the group
expectComponents(
@@ -263,32 +234,29 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
);
};
it("deletes first xblock", function() {
renderContainerPage(ABTestFixture, this);
it("can delete the first xblock", function() {
renderContainerPage(mockContainerXBlockHtml, this);
deleteComponentWithSuccess(0);
});
it("deletes middle xblock", function() {
renderContainerPage(ABTestFixture, this);
it("can delete a middle xblock", function() {
renderContainerPage(mockContainerXBlockHtml, this);
deleteComponentWithSuccess(1);
});
it("deletes last xblock", function() {
renderContainerPage(ABTestFixture, this);
it("can delete the last xblock", function() {
renderContainerPage(mockContainerXBlockHtml, this);
deleteComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
});
it('does not delete xblock when clicking No in prompt', function () {
it('does not delete when clicking No in prompt', function () {
var numRequests;
renderContainerPage(ABTestFixture, this);
renderContainerPage(mockContainerXBlockHtml, this);
numRequests = requests.length;
// click delete on the first component
clickDelete(0);
// click 'No' on delete confirmation
promptSpies.constructor.mostRecentCall.args[0].actions.secondary.click(promptSpies);
// click delete on the first component but press no
clickDelete(0, true);
// all components should still exist
expectComponents(getGroupElement(), allComponentsInGroup);
@@ -297,11 +265,23 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
expect(requests.length).toBe(numRequests);
});
it('does not delete xblock upon failure', function () {
renderContainerPage(ABTestFixture, this);
deleteComponent(0, 500);
it('shows a notification during the delete operation', function() {
var notificationSpy = edit_helpers.createNotificationSpy();
renderContainerPage(mockContainerXBlockHtml, this);
clickDelete(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/);
create_sinon.respondWithJson(requests, {});
edit_helpers.verifyNotificationHidden(notificationSpy);
});
it('does not delete an xblock upon failure', function () {
var notificationSpy = edit_helpers.createNotificationSpy();
renderContainerPage(mockContainerXBlockHtml, this);
clickDelete(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/);
create_sinon.respondWithError(requests);
edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/);
expectComponents(getGroupElement(), allComponentsInGroup);
expect(notificationSpies.hide).not.toHaveBeenCalled();
});
});
@@ -329,16 +309,8 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
// click duplicate button for given component
clickDuplicate(componentIndex);
// expect 'duplicating' notification to be shown
expectNotificationToBeShown(/Duplicating/);
// verify content of request
request = lastRequest();
request.respond(
responseCode,
{ "Content-Type": "application/json" },
JSON.stringify({'locator': 'locator-duplicated-component'})
);
expect(request.url).toEqual("/xblock");
expect(request.method).toEqual("POST");
expect(JSON.parse(request.requestBody)).toEqual(
@@ -349,6 +321,13 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
'"}'
)
);
// send the response
request.respond(
responseCode,
{ "Content-Type": "application/json" },
JSON.stringify({'locator': 'locator-duplicated-component'})
);
};
duplicateComponentWithSuccess = function(componentIndex) {
@@ -356,34 +335,117 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
// duplicate component with an 'OK' response code
duplicateComponentWithResponse(componentIndex, 200);
// expect 'duplicating' notification to be hidden
expect(notificationSpies.hide).toHaveBeenCalled();
// expect parent container to be refreshed
expect(refreshXBlockSpies).toHaveBeenCalled();
};
it("duplicates first xblock", function() {
renderContainerPage(ABTestFixture, this);
it("can duplicate the first xblock", function() {
renderContainerPage(mockContainerXBlockHtml, this);
duplicateComponentWithSuccess(0);
});
it("duplicates middle xblock", function() {
renderContainerPage(ABTestFixture, this);
it("can duplicate a middle xblock", function() {
renderContainerPage(mockContainerXBlockHtml, this);
duplicateComponentWithSuccess(1);
});
it("duplicates last xblock", function() {
renderContainerPage(ABTestFixture, this);
it("can duplicate the last xblock", function() {
renderContainerPage(mockContainerXBlockHtml, this);
duplicateComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
});
it('does not duplicate xblock upon failure', function () {
renderContainerPage(ABTestFixture, this);
duplicateComponentWithResponse(0, 500);
it('shows a notification when duplicating', function () {
var notificationSpy = edit_helpers.createNotificationSpy();
renderContainerPage(mockContainerXBlockHtml, this);
clickDuplicate(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
create_sinon.respondWithJson(requests, {"locator": "new_item"});
edit_helpers.verifyNotificationHidden(notificationSpy);
});
it('does not duplicate an xblock upon failure', function () {
var notificationSpy = edit_helpers.createNotificationSpy();
renderContainerPage(mockContainerXBlockHtml, this);
clickDuplicate(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
create_sinon.respondWithError(requests);
expectComponents(getGroupElement(), allComponentsInGroup);
expect(notificationSpies.hide).not.toHaveBeenCalled();
expect(refreshXBlockSpies).not.toHaveBeenCalled();
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
});
});
describe('createNewComponent ', function () {
var clickNewComponent, verifyComponents;
clickNewComponent = function (index) {
containerPage.$(".new-component .new-component-type a.single-template")[index].click();
};
it('sends the correct JSON to the server', function () {
renderContainerPage(mockContainerXBlockHtml, this);
clickNewComponent(0);
edit_helpers.verifyXBlockRequest(requests, {
"category": "discussion",
"type": "discussion",
"parent_locator": "locator-group-A"
});
});
it('shows a notification while creating', function () {
var notificationSpy = edit_helpers.createNotificationSpy();
renderContainerPage(mockContainerXBlockHtml, this);
clickNewComponent(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Adding/);
create_sinon.respondWithJson(requests, { });
edit_helpers.verifyNotificationHidden(notificationSpy);
});
it('does not insert component upon failure', function () {
var requestCount;
renderContainerPage(mockContainerXBlockHtml, this);
clickNewComponent(0);
requestCount = requests.length;
create_sinon.respondWithError(requests);
// No new requests should be made to refresh the view
expect(requests.length).toBe(requestCount);
expectComponents(getGroupElement(), allComponentsInGroup);
});
describe('Template Picker', function() {
var showTemplatePicker, verifyCreateHtmlComponent,
mockXBlockHtml = readFixtures('mock/mock-xblock.underscore');
showTemplatePicker = function() {
containerPage.$('.new-component .new-component-type a.multiple-templates')[0].click();
};
verifyCreateHtmlComponent = function(test, templateIndex, expectedRequest) {
var xblockCount;
renderContainerPage(mockContainerXBlockHtml, test);
showTemplatePicker();
xblockCount = containerPage.$('.studio-xblock-wrapper').length;
containerPage.$('.new-component-html a')[templateIndex].click();
edit_helpers.verifyXBlockRequest(requests, expectedRequest);
create_sinon.respondWithJson(requests, {"locator": "new_item"});
respondWithHtml(mockXBlockHtml);
expect(containerPage.$('.studio-xblock-wrapper').length).toBe(xblockCount + 1);
};
it('can add an HTML component without a template', function() {
verifyCreateHtmlComponent(this, 0, {
"category": "html",
"parent_locator": "locator-group-A"
});
});
it('can add an HTML component with a template', function() {
verifyCreateHtmlComponent(this, 1, {
"category": "html",
"boilerplate" : "announcement.yaml",
"parent_locator": "locator-group-A"
});
});
});
});
});

View File

@@ -1,240 +1,178 @@
define(["coffee/src/views/unit", "js/models/module_info", "js/spec_helpers/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");
// There was a problem with order of returned parameters in strings.
// Changed to compare objects instead strings.
expect(JSON.parse(request.requestBody)).toEqual(JSON.parse(json));
define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/module_info",
"js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers", "jasmine-stealth"],
function ($, _, jasmine, UnitEditView, ModuleModel, create_sinon, edit_helpers) {
var requests, unitView, initialize, respondWithHtml, verifyComponents, i;
respondWithHtml = function(html, requestIndex) {
create_sinon.respondWithJson(
requests,
{ html: html, "resources": [] },
requestIndex
);
};
var verifyComponents = function (unit, locators) {
initialize = function(test) {
var mockXBlockHtml = readFixtures('mock/mock-unit-page-xblock.underscore'),
model;
requests = create_sinon.requests(test);
model = new ModuleModel({
id: 'unit_locator',
state: 'draft'
});
unitView = new UnitEditView({
el: $('.main-wrapper'),
templates: edit_helpers.mockComponentTemplates,
model: model
});
// Respond with renderings for the two xblocks in the unit
respondWithHtml(mockXBlockHtml, 0);
respondWithHtml(mockXBlockHtml, 1);
};
verifyComponents = function (unit, locators) {
var components = unit.$(".component");
expect(components.length).toBe(locators.length);
for (var i=0; i < locators.length; i++) {
for (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();
};
beforeEach(function() {
edit_helpers.installMockXBlock();
describe('duplicateComponent ', function () {
var duplicateFixture =
'<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"/> \
<ul class="component-actions"> \
<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button"><i class="icon-copy"></i><span class="sr"></span>Duplicate</span></a> \
</ul> \
</li> \
<li class="component" data-locator="loc_2"> \
<div class="wrapper wrapper-component-editor"/> \
<ul class="component-actions"> \
<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button"><i class="icon-copy"></i><span class="sr"></span>Duplicate</span></a> \
</ul> \
</li> \
</ol> \
</div>';
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']);
});
// needed to stub out the ajax
window.analytics = jasmine.createSpyObj('analytics', ['track']);
window.course_location_analytics = jasmine.createSpy('course_location_analytics');
window.unit_location_analytics = jasmine.createSpy('unit_location_analytics');
});
describe('saveNewComponent ', function () {
var newComponentFixture =
'<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"/> \
</li> \
<li class="component" data-locator="loc_2"> \
<div class="wrapper wrapper-component-editor"/> \
</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> \
</div>';
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']);
});
afterEach(function () {
edit_helpers.uninstallMockXBlock();
});
describe("Disabled edit/publish links during ajax call", function() {
var unit,
link,
draft_states = [
{
state: "draft",
selector: ".publish-draft"
},
{
state: "public",
selector: ".create-draft"
}
],
editLinkFixture =
'<div class="main-wrapper edit-state-draft" data-locator="unit_locator"> \
<div class="unit-settings window"> \
<h4 class="header">Unit Settings</h4> \
<div class="window-contents"> \
<div class="row published-alert"> \
<p class="edit-draft-message"> \
<a href="#" class="create-draft">edit a draft</a> \
</p> \
<p class="publish-draft-message"> \
<a href="#" class="publish-draft">replace it with this draft</a> \
</p> \
</div> \
</div> \
</div> \
</div>';
function test_link_disabled_during_ajax_call(draft_state) {
beforeEach(function () {
setFixtures(editLinkFixture);
unit = new UnitEditView({
el: $('.main-wrapper'),
model: new ModuleModel({
id: 'unit_locator',
state: draft_state['state']
})
describe("UnitEditView", function() {
beforeEach(function() {
edit_helpers.installEditTemplates();
appendSetFixtures(readFixtures('mock/mock-unit-page.underscore'));
});
describe('duplicateComponent', function() {
var clickDuplicate;
clickDuplicate = function (index) {
unitView.$(".duplicate-button")[index].click();
};
it('sends the correct JSON to the server', function () {
initialize(this);
clickDuplicate(0);
edit_helpers.verifyXBlockRequest(requests, {
"duplicate_source_locator": "loc_1",
"parent_locator": "unit_locator"
});
// needed to stub out the ajax
window.analytics = jasmine.createSpyObj('analytics', ['track']);
window.course_location_analytics = jasmine.createSpy('course_location_analytics');
window.unit_location_analytics = jasmine.createSpy('unit_location_analytics');
});
it("reenables the " + draft_state['selector'] + " link once the ajax call returns", function() {
runs(function(){
spyOn($, "ajax").andCallThrough();
spyOn($.fn, 'addClass').andCallThrough();
spyOn($.fn, 'removeClass').andCallThrough();
link = $(draft_state['selector']);
it('inserts duplicated component immediately after source upon success', function () {
initialize(this);
clickDuplicate(0);
create_sinon.respondWithJson(requests, {"locator": "duplicated_item"});
verifyComponents(unitView, ['loc_1', 'duplicated_item', 'loc_2']);
});
it('inserts duplicated component at end if source at end', function () {
initialize(this);
clickDuplicate(1);
create_sinon.respondWithJson(requests, {"locator": "duplicated_item"});
verifyComponents(unitView, ['loc_1', 'loc_2', 'duplicated_item']);
});
it('shows a notification while duplicating', function () {
var notificationSpy = edit_helpers.createNotificationSpy();
initialize(this);
clickDuplicate(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
create_sinon.respondWithJson(requests, {"locator": "new_item"});
edit_helpers.verifyNotificationHidden(notificationSpy);
});
it('does not insert duplicated component upon failure', function () {
initialize(this);
clickDuplicate(0);
create_sinon.respondWithError(requests);
verifyComponents(unitView, ['loc_1', 'loc_2']);
});
});
describe('createNewComponent ', function () {
var clickNewComponent;
clickNewComponent = function () {
unitView.$(".new-component .new-component-type a.single-template").click();
};
it('sends the correct JSON to the server', function () {
initialize(this);
clickNewComponent();
edit_helpers.verifyXBlockRequest(requests, {
"category": "discussion",
"type": "discussion",
"parent_locator": "unit_locator"
});
});
it('inserts new component at end', function () {
initialize(this);
clickNewComponent();
create_sinon.respondWithJson(requests, {"locator": "new_item"});
verifyComponents(unitView, ['loc_1', 'loc_2', 'new_item']);
});
it('shows a notification while creating', function () {
var notificationSpy = edit_helpers.createNotificationSpy();
initialize(this);
clickNewComponent();
edit_helpers.verifyNotificationShowing(notificationSpy, /Adding/);
create_sinon.respondWithJson(requests, {"locator": "new_item"});
edit_helpers.verifyNotificationHidden(notificationSpy);
});
it('does not insert new component upon failure', function () {
initialize(this);
clickNewComponent();
create_sinon.respondWithError(requests);
verifyComponents(unitView, ['loc_1', 'loc_2']);
});
});
describe("Disabled edit/publish links during ajax call", function() {
var link, i,
draft_states = [
{
state: "draft",
selector: ".publish-draft"
},
{
state: "public",
selector: ".create-draft"
}
];
function test_link_disabled_during_ajax_call(draft_state) {
it("re-enables the " + draft_state.selector + " link once the ajax call returns", function() {
initialize(this);
link = $(draft_state.selector);
expect(link).not.toHaveClass('is-disabled');
link.click();
expect(link).toHaveClass('is-disabled');
create_sinon.respondWithError(requests);
expect(link).not.toHaveClass('is-disabled');
});
waitsFor(function(){
// wait for "is-disabled" to be removed as a class
return !($(draft_state['selector']).hasClass("is-disabled"));
}, 500);
runs(function(){
// check that the `is-disabled` class was added and removed
expect($.fn.addClass).toHaveBeenCalledWith("is-disabled");
expect($.fn.removeClass).toHaveBeenCalledWith("is-disabled");
}
// make sure the link finishes without the `is-disabled` class
expect(link).not.toHaveClass("is-disabled");
// affirm that ajax was called
expect($.ajax).toHaveBeenCalled();
});
});
};
for (var i = 0; i < draft_states.length; i++) {
test_link_disabled_during_ajax_call(draft_states[i]);
};
for (i = 0; i < draft_states.length; i++) {
test_link_disabled_during_ajax_call(draft_states[i]);
}
});
});
}
);
});

View File

@@ -29,7 +29,7 @@ define([ "jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helper
var mockXBlockEditorHtml;
beforeEach(function () {
edit_helpers.installMockXBlock(mockSaveResponse);
edit_helpers.installMockXBlock();
});
afterEach(function() {

View File

@@ -1,4 +1,4 @@
define(["sinon"], function(sinon) {
define(["sinon", "underscore"], function(sinon, _) {
var fakeServer, fakeRequests, respondWithJson, respondWithError;
/* These utility methods are used by Jasmine tests to create a mock server or
@@ -46,14 +46,18 @@ define(["sinon"], function(sinon) {
};
respondWithJson = function(requests, jsonResponse, requestIndex) {
requestIndex = requestIndex || requests.length - 1;
if (_.isUndefined(requestIndex)) {
requestIndex = requests.length - 1;
}
requests[requestIndex].respond(200,
{ "Content-Type": "application/json" },
JSON.stringify(jsonResponse));
};
respondWithError = function(requests, requestIndex) {
requestIndex = requestIndex || requests.length - 1;
if (_.isUndefined(requestIndex)) {
requestIndex = requests.length - 1;
}
requests[requestIndex].respond(500,
{ "Content-Type": "application/json" },
JSON.stringify({ }));

View File

@@ -2,22 +2,14 @@
* Provides helper methods for invoking Studio editors in Jasmine tests.
*/
define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers/modal_helpers",
"js/views/modals/edit_xblock", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function($, _, create_sinon, modal_helpers, EditXBlockModal) {
"js/views/modals/edit_xblock", "js/collections/component_template",
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function($, _, create_sinon, modal_helpers, EditXBlockModal, ComponentTemplates) {
var editorTemplate = readFixtures('metadata-editor.underscore'),
numberEntryTemplate = readFixtures('metadata-number-entry.underscore'),
stringEntryTemplate = readFixtures('metadata-string-entry.underscore'),
editXBlockModalTemplate = readFixtures('edit-xblock-modal.underscore'),
editorModeButtonTemplate = readFixtures('editor-mode-button.underscore'),
installMockXBlock,
uninstallMockXBlock,
installMockXModule,
uninstallMockXModule,
installEditTemplates,
showEditModal;
var installMockXBlock, uninstallMockXBlock, installMockXModule, uninstallMockXModule,
mockComponentTemplates, installEditTemplates, showEditModal, verifyXBlockRequest;
installMockXBlock = function(mockResult) {
installMockXBlock = function() {
window.MockXBlock = function(runtime, element) {
return {
runtime: runtime
@@ -41,17 +33,52 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
window.MockDescriptor = null;
};
mockComponentTemplates = new ComponentTemplates([
{
templates: [
{
category: 'discussion',
display_name: 'Discussion'
}],
type: 'discussion'
}, {
"templates": [
{
"category": "html",
"boilerplate_name": null,
"display_name": "Text"
}, {
"category": "html",
"boilerplate_name": "announcement.yaml",
"display_name": "Announcement"
}, {
"category": "html",
"boilerplate_name": "raw.yaml",
"display_name": "Raw HTML"
}],
"type": "html"
}],
{
parse: true
});
installEditTemplates = function(append) {
modal_helpers.installModalTemplates(append);
// Add templates needed by the add XBlock menu
modal_helpers.installTemplate('add-xblock-component');
modal_helpers.installTemplate('add-xblock-component-button');
modal_helpers.installTemplate('add-xblock-component-menu');
modal_helpers.installTemplate('add-xblock-component-menu-problem');
// Add templates needed by the edit XBlock modal
appendSetFixtures($("<script>", { id: "edit-xblock-modal-tpl", type: "text/template" }).text(editXBlockModalTemplate));
appendSetFixtures($("<script>", { id: "editor-mode-button-tpl", type: "text/template" }).text(editorModeButtonTemplate));
modal_helpers.installTemplate('edit-xblock-modal');
modal_helpers.installTemplate('editor-mode-button');
// Add templates needed by the settings editor
appendSetFixtures($("<script>", {id: "metadata-editor-tpl", type: "text/template"}).text(editorTemplate));
appendSetFixtures($("<script>", {id: "metadata-number-entry", type: "text/template"}).text(numberEntryTemplate));
appendSetFixtures($("<script>", {id: "metadata-string-entry", type: "text/template"}).text(stringEntryTemplate));
modal_helpers.installTemplate('metadata-editor');
modal_helpers.installTemplate('metadata-number-entry');
modal_helpers.installTemplate('metadata-string-entry');
};
showEditModal = function(requests, xblockElement, model, mockHtml, options) {
@@ -64,12 +91,22 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
return modal;
};
verifyXBlockRequest = function (requests, expectedJson) {
var request = requests[requests.length - 1],
actualJson = JSON.parse(request.requestBody);
expect(request.url).toEqual("/xblock");
expect(request.method).toEqual("POST");
expect(actualJson).toEqual(expectedJson);
};
return $.extend(modal_helpers, {
'installMockXBlock': installMockXBlock,
'uninstallMockXBlock': uninstallMockXBlock,
'installMockXModule': installMockXModule,
'uninstallMockXModule': uninstallMockXModule,
'mockComponentTemplates': mockComponentTemplates,
'installEditTemplates': installEditTemplates,
'showEditModal': showEditModal
'showEditModal': showEditModal,
'verifyXBlockRequest': verifyXBlockRequest
});
});

View File

@@ -3,10 +3,7 @@
*/
define(["jquery", "js/spec_helpers/view_helpers"],
function($, view_helpers) {
var basicModalTemplate = readFixtures('basic-modal.underscore'),
modalButtonTemplate = readFixtures('modal-button.underscore'),
feedbackTemplate = readFixtures('system-feedback.underscore'),
installModalTemplates,
var installModalTemplates,
getModalElement,
isShowingModal,
hideModalIfShowing,
@@ -15,8 +12,8 @@ define(["jquery", "js/spec_helpers/view_helpers"],
installModalTemplates = function(append) {
view_helpers.installViewTemplates(append);
appendSetFixtures($("<script>", { id: "basic-modal-tpl", type: "text/template" }).text(basicModalTemplate));
appendSetFixtures($("<script>", { id: "modal-button-tpl", type: "text/template" }).text(modalButtonTemplate));
view_helpers.installTemplate('basic-modal');
view_helpers.installTemplate('modal-button');
};
getModalElement = function(modal) {

View File

@@ -1,20 +1,49 @@
/**
* Provides helper methods for invoking Studio modal windows in Jasmine tests.
*/
define(["jquery"],
function($) {
var feedbackTemplate = readFixtures('system-feedback.underscore'),
installViewTemplates;
define(["jquery", "js/views/feedback_notification", "js/spec_helpers/create_sinon"],
function($, NotificationView, create_sinon) {
var installTemplate, installViewTemplates, createNotificationSpy, verifyNotificationShowing,
verifyNotificationHidden;
installViewTemplates = function(append) {
if (append) {
appendSetFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate));
installTemplate = function(templateName, isFirst) {
var template = readFixtures(templateName + '.underscore'),
templateId = templateName + '-tpl';
if (isFirst) {
setFixtures($("<script>", { id: templateId, type: "text/template" }).text(template));
} else {
setFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate));
appendSetFixtures($("<script>", { id: templateId, type: "text/template" }).text(template));
}
};
installViewTemplates = function(append) {
installTemplate('system-feedback', !append);
appendSetFixtures('<div id="page-notification"></div>');
};
createNotificationSpy = function() {
var notificationSpy = spyOnConstructor(NotificationView, "Mini", ["show", "hide"]);
notificationSpy.show.andReturn(notificationSpy);
return notificationSpy;
};
verifyNotificationShowing = function(notificationSpy, text) {
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);
};
verifyNotificationHidden = function(notificationSpy) {
expect(notificationSpy.hide).toHaveBeenCalled();
};
return {
'installViewTemplates': installViewTemplates
'installTemplate': installTemplate,
'installViewTemplates': installViewTemplates,
'createNotificationSpy': createNotificationSpy,
'verifyNotificationShowing': verifyNotificationShowing,
'verifyNotificationHidden': verifyNotificationHidden
};
});

View File

@@ -7,11 +7,11 @@
* getUpdateUrl: a utility method that returns the xblock update URL, appending
* the location if passed in.
*/
define([], function () {
define(["underscore"], function (_) {
var urlRoot = '/xblock';
var getUpdateUrl = function (locator) {
if (locator === undefined) {
if (_.isUndefined(locator)) {
return urlRoot;
}
else {

View File

@@ -0,0 +1,20 @@
define(["jquery", "underscore"], function($, _) {
/**
* Loads the named template from the page, or logs an error if it fails.
* @param name The name of the template.
* @returns The loaded template.
*/
var loadTemplate = function(name) {
var templateSelector = "#" + name + "-tpl",
templateText = $(templateSelector).text();
if (!templateText) {
console.error("Failed to load " + name + " template");
}
return _.template(templateText);
};
return {
loadTemplate: loadTemplate
};
});

View File

@@ -1,5 +1,6 @@
define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"],
function ($, _, Backbone, IframeUtils) {
define(["jquery", "underscore", "backbone", "gettext", "js/utils/handle_iframe_binding", "js/utils/templates",
"js/views/feedback_notification", "js/views/feedback_prompt"],
function ($, _, Backbone, gettext, IframeUtils, TemplateUtils, NotificationView, PromptView) {
/*
This view is extended from backbone to provide useful functionality for all Studio views.
This functionality includes:
@@ -60,16 +61,60 @@ define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"],
$('.ui-loading').hide();
},
/**
* Confirms with the user whether to run an operation or not, and then runs it if desired.
*/
confirmThenRunOperation: function(title, message, actionLabel, operation) {
var self = this;
return new PromptView.Warning({
title: title,
message: message,
actions: {
primary: {
text: actionLabel,
click: function(prompt) {
prompt.hide();
operation();
}
},
secondary: {
text: gettext('Cancel'),
click: function(prompt) {
return prompt.hide();
}
}
}
}).show();
},
/**
* Shows a progress message for the duration of an asynchronous operation.
* Note: this does not remove the notification upon failure because an error
* will be shown that shouldn't be removed.
* @param message The message to show.
* @param operation A function that returns a promise representing the operation.
*/
runOperationShowingMessage: function(message, operation) {
var notificationView;
notificationView = new NotificationView.Mini({
title: gettext(message)
});
notificationView.show();
return operation().done(function() {
notificationView.hide();
});
},
/**
* Disables a given element when a given operation is running.
* @param {jQuery} element: the element to be disabled.
* @param operation: the operation during whose duration the
* element should be disabled. The operation should return
* a jquery promise.
* a JQuery promise.
*/
disableElementWhileRunning: function(element, operation) {
element.addClass("is-disabled");
operation().always(function() {
return operation().always(function() {
element.removeClass("is-disabled");
});
},
@@ -80,12 +125,38 @@ define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"],
* @returns The loaded template.
*/
loadTemplate: function(name) {
var templateSelector = "#" + name + "-tpl",
templateText = $(templateSelector).text();
if (!templateText) {
console.error("Failed to load " + name + " template");
}
return _.template(templateText);
return TemplateUtils.loadTemplate(name);
},
/**
* Returns the relative position that the element is scrolled from the top of the view port.
* @param element The element in question.
*/
getScrollOffset: function(element) {
var elementTop = element.offset().top;
return elementTop - $(window).scrollTop();
},
/**
* Scrolls the window so that the element is scrolled down to the specified relative position
* from the top of the view port.
* @param element The element in question.
* @param offset The amount by which the element should be scrolled from the top of the view port.
*/
setScrollOffset: function(element, offset) {
var elementTop = element.offset().top,
newScrollTop = elementTop - offset;
this.setScrollTop(newScrollTop);
},
/**
* Performs an animated scroll so that the window has the specified scroll top.
* @param scrollTop The desired scroll top for the window.
*/
setScrollTop: function(scrollTop) {
$('html, body').animate({
scrollTop: scrollTop
}, 500);
}
});

View File

@@ -0,0 +1,74 @@
/**
* This is a simple component that renders add buttons for all available XBlock template types.
*/
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/components/add_xblock_button",
"js/views/components/add_xblock_menu"],
function ($, _, gettext, BaseView, AddXBlockButton, AddXBlockMenu) {
var AddXBlockComponent = BaseView.extend({
events: {
'click .new-component .new-component-type a.multiple-templates': 'showComponentTemplates',
'click .new-component .new-component-type a.single-template': 'createNewComponent',
'click .new-component .cancel-button': 'closeNewComponent',
'click .new-component-templates .new-component-template a': 'createNewComponent',
'click .new-component-templates .cancel-button': 'closeNewComponent'
},
initialize: function(options) {
BaseView.prototype.initialize.call(this, options);
this.template = this.loadTemplate('add-xblock-component');
},
render: function () {
if (!this.$el.html()) {
var that = this;
this.$el.html(this.template({}));
this.collection.each(
function (componentModel) {
var view, menu;
view = new AddXBlockButton({model: componentModel});
that.$el.find('.new-component-type').append(view.render().el);
menu = new AddXBlockMenu({model: componentModel});
that.$el.append(menu.render().el);
}
);
}
},
showComponentTemplates: function(event) {
var type;
event.preventDefault();
event.stopPropagation();
type = $(event.currentTarget).data('type');
this.$('.new-component').slideUp(250);
this.$('.new-component-' + type).slideDown(250);
},
closeNewComponent: function(event) {
event.preventDefault();
event.stopPropagation();
this.$('.new-component').slideDown(250);
this.$('.new-component-templates').slideUp(250);
},
createNewComponent: function(event) {
var self = this,
element = $(event.currentTarget),
saveData = element.data(),
oldOffset = this.getScrollOffset(this.$el);
event.preventDefault();
this.closeNewComponent(event);
this.runOperationShowingMessage(
gettext('Adding&hellip;'),
_.bind(this.options.createComponent, this, saveData, element)
).always(function() {
// Restore the scroll position of the buttons so that the new
// component appears above them.
self.setScrollOffset(self.$el, oldOffset);
});
}
});
return AddXBlockComponent;
}); // end define();

View File

@@ -0,0 +1,13 @@
define(["js/views/baseview"],
function (BaseView) {
return BaseView.extend({
tagName: "li",
initialize: function () {
BaseView.prototype.initialize.call(this);
this.template = this.loadTemplate("add-xblock-component-button");
this.$el.html(this.template({type: this.model.type, templates: this.model.templates}));
}
});
}); // end define();

View File

@@ -0,0 +1,19 @@
define(["jquery", "js/views/baseview"],
function ($, BaseView) {
return BaseView.extend({
className: function () {
return "new-component-templates new-component-" + this.model.type;
},
initialize: function () {
BaseView.prototype.initialize.call(this);
var template_name = this.model.type === "problem" ? "add-xblock-component-menu-problem" :
"add-xblock-component-menu";
this.template = this.loadTemplate(template_name);
this.$el.html(this.template({type: this.model.type, templates: this.model.templates}));
// Make the tabs on problems into "real tabs"
this.$('.tab-group').tabs();
}
});
}); // end define();

View File

@@ -1,10 +1,13 @@
define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification"],
function ($, _, XBlockView, ModuleUtils, gettext, NotificationView) {
var reorderableClass = '.reorderable-container',
studioXBlockWrapperClass = '.studio-xblock-wrapper';
var ContainerView = XBlockView.extend({
xblockReady: function () {
XBlockView.prototype.xblockReady.call(this);
var verticalContainer = this.$('.vertical-container'),
var reorderableContainer = this.$(reorderableClass),
alreadySortable = this.$('.ui-sortable'),
newParent,
oldParent,
@@ -12,13 +15,13 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
alreadySortable.sortable("destroy");
verticalContainer.sortable({
reorderableContainer.sortable({
handle: '.drag-handle',
stop: function (event, ui) {
var saving, hideSaving, removeFromParent;
if (oldParent === undefined) {
if (_.isUndefined(oldParent)) {
// If no actual change occurred,
// oldParent will never have been set.
return;
@@ -55,7 +58,7 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
// be null if the change is related to the list the element
// was originally in (the case of a move within the same container
// or the deletion from a container when moving to a new container).
var parent = $(event.target).closest('.wrapper-xblock');
var parent = $(event.target).closest(studioXBlockWrapperClass);
if (ui.sender) {
// Move to a new container (the addition part).
newParent = parent;
@@ -69,8 +72,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
placeholder: 'component-placeholder',
forcePlaceholderSize: true,
axis: 'y',
items: '> .vertical-element',
connectWith: ".vertical-container",
items: '> .is-draggable',
connectWith: reorderableClass,
tolerance: "pointer"
});
@@ -79,10 +82,10 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
reorder: function (targetParent, successCallback) {
var children, childLocators;
// Find descendants with class "wrapper-xblock" whose parent == targetParent.
// Find descendants with class "studio-xblock-wrapper" whose parent === targetParent.
// This is necessary to filter our grandchildren, great-grandchildren, etc.
children = targetParent.find('.wrapper-xblock').filter(function () {
var parent = $(this).parent().closest('.wrapper-xblock');
children = targetParent.find(studioXBlockWrapperClass).filter(function () {
var parent = $(this).parent().closest(studioXBlockWrapperClass);
return parent.data('locator') === targetParent.data('locator');
});
@@ -107,7 +110,10 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
}
}
});
},
refresh: function() {
this.$(reorderableClass).sortable('refresh');
}
});

View File

@@ -1,15 +1,16 @@
define(["js/views/baseview", "underscore", "underscore.string", "jquery"], function(BaseView, _, str, $) {
var SystemFeedback = BaseView.extend({
options: {
title: "",
message: "",
intent: null, // "warning", "confirmation", "error", "announcement", "step-required", etc
type: null, // "alert", "notification", or "prompt": set by subclass
shown: true, // is this view currently being shown?
icon: true, // should we render an icon related to the message intent?
closeIcon: true, // should we render a close button in the top right corner?
minShown: 0, // length of time after this view has been shown before it can be hidden (milliseconds)
maxShown: Infinity // length of time after this view has been shown before it will be automatically hidden (milliseconds)
define(["jquery", "underscore", "underscore.string", "backbone", "js/utils/templates"],
function($, _, str, Backbone, TemplateUtils) {
var SystemFeedback = Backbone.View.extend({
options: {
title: "",
message: "",
intent: null, // "warning", "confirmation", "error", "announcement", "step-required", etc
type: null, // "alert", "notification", or "prompt": set by subclass
shown: true, // is this view currently being shown?
icon: true, // should we render an icon related to the message intent?
closeIcon: true, // should we render a close button in the top right corner?
minShown: 0, // length of time after this view has been shown before it can be hidden (milliseconds)
maxShown: Infinity // length of time after this view has been shown before it will be automatically hidden (milliseconds)
/* Could also have an "actions" hash: here is an example demonstrating
the expected structure. For each action, by default the framework
@@ -38,100 +39,108 @@ define(["js/views/baseview", "underscore", "underscore.string", "jquery"], funct
]
}
*/
},
initialize: function() {
if(!this.options.type) {
throw "SystemFeedback: type required (given " +
JSON.stringify(this.options) + ")";
}
if(!this.options.intent) {
throw "SystemFeedback: intent required (given " +
JSON.stringify(this.options) + ")";
}
this.template = this.loadTemplate("system-feedback");
this.setElement($("#page-"+this.options.type));
// handle single "secondary" action
if (this.options.actions && this.options.actions.secondary &&
!_.isArray(this.options.actions.secondary)) {
this.options.actions.secondary = [this.options.actions.secondary];
}
return this;
},
// public API: show() and hide()
show: function() {
clearTimeout(this.hideTimeout);
this.options.shown = true;
this.shownAt = new Date();
this.render();
if($.isNumeric(this.options.maxShown)) {
this.hideTimeout = setTimeout(_.bind(this.hide, this),
this.options.maxShown);
}
return this;
},
hide: function() {
if(this.shownAt && $.isNumeric(this.options.minShown) &&
this.options.minShown > new Date() - this.shownAt)
{
},
initialize: function() {
if (!this.options.type) {
throw "SystemFeedback: type required (given " +
JSON.stringify(this.options) + ")";
}
if (!this.options.intent) {
throw "SystemFeedback: intent required (given " +
JSON.stringify(this.options) + ")";
}
this.template = TemplateUtils.loadTemplate("system-feedback");
this.setElement($("#page-" + this.options.type));
// handle single "secondary" action
if (this.options.actions && this.options.actions.secondary &&
!_.isArray(this.options.actions.secondary)) {
this.options.actions.secondary = [this.options.actions.secondary];
}
return this;
},
// public API: show() and hide()
show: function() {
clearTimeout(this.hideTimeout);
this.hideTimeout = setTimeout(_.bind(this.hide, this),
this.options.minShown - (new Date() - this.shownAt));
} else {
this.options.shown = false;
delete this.shownAt;
this.options.shown = true;
this.shownAt = new Date();
this.render();
if ($.isNumeric(this.options.maxShown)) {
this.hideTimeout = setTimeout(_.bind(this.hide, this),
this.options.maxShown);
}
return this;
},
hide: function() {
if (this.shownAt && $.isNumeric(this.options.minShown) &&
this.options.minShown > new Date() - this.shownAt) {
clearTimeout(this.hideTimeout);
this.hideTimeout = setTimeout(_.bind(this.hide, this),
this.options.minShown - (new Date() - this.shownAt));
} else {
this.options.shown = false;
delete this.shownAt;
this.render();
}
return this;
},
// the rest of the API should be considered semi-private
events: {
"click .action-close": "hide",
"click .action-primary": "primaryClick",
"click .action-secondary": "secondaryClick"
},
render: function() {
// there can be only one active view of a given type at a time: only
// one alert, only one notification, only one prompt. Therefore, we'll
// use a singleton approach.
var singleton = SystemFeedback["active_" + this.options.type];
if (singleton && singleton !== this) {
singleton.stopListening();
singleton.undelegateEvents();
}
this.$el.html(this.template(this.options));
SystemFeedback["active_" + this.options.type] = this;
return this;
},
primaryClick: function(event) {
var actions, primary;
actions = this.options.actions;
if (!actions) { return; }
primary = actions.primary;
if (!primary) { return; }
if (primary.preventDefault !== false) {
event.preventDefault();
}
if (primary.click) {
primary.click.call(event.target, this, event);
}
},
secondaryClick: function(event) {
var actions, secondaryList, secondary, i;
actions = this.options.actions;
if (!actions) { return; }
secondaryList = actions.secondary;
if (!secondaryList) { return; }
// which secondary action was clicked?
i = 0; // default to the first secondary action (easier for testing)
if (event && event.target) {
i = _.indexOf(this.$(".action-secondary"), event.target);
}
secondary = secondaryList[i];
if (secondary.preventDefault !== false) {
event.preventDefault();
}
if (secondary.click) {
secondary.click.call(event.target, this, event);
}
}
return this;
},
// the rest of the API should be considered semi-private
events: {
"click .action-close": "hide",
"click .action-primary": "primaryClick",
"click .action-secondary": "secondaryClick"
},
render: function() {
// there can be only one active view of a given type at a time: only
// one alert, only one notification, only one prompt. Therefore, we'll
// use a singleton approach.
var singleton = SystemFeedback["active_"+this.options.type];
if(singleton && singleton !== this) {
singleton.stopListening();
singleton.undelegateEvents();
}
this.$el.html(this.template(this.options));
SystemFeedback["active_"+this.options.type] = this;
return this;
},
primaryClick: function(event) {
var actions = this.options.actions;
if(!actions) { return; }
var primary = actions.primary;
if(!primary) { return; }
if(primary.preventDefault !== false) {
event.preventDefault();
}
if(primary.click) {
primary.click.call(event.target, this, event);
}
},
secondaryClick: function(event) {
var actions = this.options.actions;
if(!actions) { return; }
var secondaryList = actions.secondary;
if(!secondaryList) { return; }
// which secondary action was clicked?
var i = 0; // default to the first secondary action (easier for testing)
if(event && event.target) {
i = _.indexOf(this.$(".action-secondary"), event.target);
}
var secondary = secondaryList[i];
if(secondary.preventDefault !== false) {
event.preventDefault();
}
if(secondary.click) {
secondary.click.call(event.target, this, event);
}
}
});
return SystemFeedback;
});
return SystemFeedback;
});

View File

@@ -1,11 +1,13 @@
/**
* XBlockContainerView is used to display an xblock which has children, and allows the
* user to interact with the children.
* XBlockContainerPage is used to display Studio's container page for an xblock which has children.
* This page allows the user to understand and manipulate the xblock and its children.
*/
define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt", "js/views/baseview", "js/views/container", "js/views/xblock", "js/views/modals/edit_xblock", "js/models/xblock_info"],
function ($, _, gettext, NotificationView, PromptView, BaseView, ContainerView, XBlockView, EditXBlockModal, XBlockInfo) {
var XBlockContainerView = BaseView.extend({
define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
"js/views/baseview", "js/views/container", "js/views/xblock", "js/views/components/add_xblock",
"js/views/modals/edit_xblock", "js/models/xblock_info"],
function ($, _, gettext, NotificationView, BaseView, ContainerView, XBlockView, AddXBlockComponent,
EditXBlockModal, XBlockInfo) {
var XBlockContainerPage = BaseView.extend({
// takes XBlockInfo as a model
view: 'container_preview',
@@ -39,7 +41,8 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
success: function(xblock) {
if (xblockView.hasChildXBlocks()) {
xblockView.$el.removeClass('is-hidden');
self.addButtonActions(xblockView.$el);
self.renderAddXBlockComponents();
self.onXBlockRefresh(xblockView);
} else {
noContentElement.removeClass('is-hidden');
}
@@ -50,137 +53,176 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
},
findXBlockElement: function(target) {
return $(target).closest('[data-locator]');
return $(target).closest('.studio-xblock-wrapper');
},
getURLRoot: function() {
return this.xblockView.model.urlRoot;
},
onXBlockRefresh: function(xblockView) {
this.addButtonActions(xblockView.$el);
this.xblockView.refresh();
},
renderAddXBlockComponents: function() {
var self = this;
this.$('.add-xblock-component').each(function(index, element) {
var component = new AddXBlockComponent({
el: element,
createComponent: _.bind(self.createComponent, self),
collection: self.options.templates
});
component.render();
});
},
addButtonActions: function(element) {
var self = this;
element.find('.edit-button').click(function(event) {
var modal,
target = event.target,
xblockElement = self.findXBlockElement(target);
event.preventDefault();
modal = new EditXBlockModal({ });
modal.edit(xblockElement, self.model,
{
refresh: function(xblockInfo) {
self.refreshXBlock(xblockInfo, xblockElement);
}
});
self.editComponent(self.findXBlockElement(event.target));
});
element.find('.duplicate-button').click(function(event) {
event.preventDefault();
self.duplicateComponent(
self.findXBlockElement(event.target)
);
self.duplicateComponent(self.findXBlockElement(event.target));
});
element.find('.delete-button').click(function(event) {
event.preventDefault();
self.deleteComponent(
self.findXBlockElement(event.target)
);
self.deleteComponent(self.findXBlockElement(event.target));
});
},
editComponent: function(xblockElement) {
var self = this,
modal = new EditXBlockModal({ });
modal.edit(xblockElement, this.model, {
refresh: function() {
self.refreshXBlock(xblockElement);
}
});
},
createComponent: function(template, target) {
// A placeholder element is created in the correct location for the new xblock
// 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 parentElement = this.findXBlockElement(target),
parentLocator = parentElement.data('locator'),
buttonPanel = target.closest('.add-xblock-component'),
listPanel = buttonPanel.prev(),
scrollOffset = this.getScrollOffset(buttonPanel),
placeholderElement = $('<div></div>').appendTo(listPanel),
requestData = _.extend(template, {
parent_locator: parentLocator
});
return $.postJSON(this.getURLRoot(), requestData,
_.bind(this.onNewXBlock, this, placeholderElement, scrollOffset));
},
duplicateComponent: function(xblockElement) {
// A placeholder element is created in the correct location for the duplicate xblock
// 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,
parentElement = self.findXBlockElement(xblockElement.parent()),
duplicating = new NotificationView.Mini({
title: gettext('Duplicating&hellip;')
parent = xblockElement.parent();
this.runOperationShowingMessage(gettext('Duplicating&hellip;'),
function() {
var scrollOffset = self.getScrollOffset(xblockElement),
placeholderElement = $('<div></div>').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));
});
duplicating.show();
return $.postJSON(self.getURLRoot(), {
duplicate_source_locator: xblockElement.data('locator'),
parent_locator: parentElement.data('locator')
}, function(data) {
// copy the element
var duplicatedElement = xblockElement.clone(false);
// place it after the original element
xblockElement.after(duplicatedElement);
// update its locator id
duplicatedElement.attr('data-locator', data.locator);
// have it refresh itself
self.refreshXBlockElement(duplicatedElement);
// hide the notification
duplicating.hide();
});
},
deleteComponent: function(xblockElement) {
var self = this, deleting;
return new PromptView.Warning({
title: gettext('Delete this component?'),
message: gettext('Deleting this component is permanent and cannot be undone.'),
actions: {
primary: {
text: gettext('Yes, delete this component'),
click: function(prompt) {
prompt.hide();
deleting = new NotificationView.Mini({
title: gettext('Deleting&hellip;')
});
deleting.show();
var self = this;
this.confirmThenRunOperation(gettext('Delete this component?'),
gettext('Deleting this component is permanent and cannot be undone.'),
gettext('Yes, delete this component'),
function() {
self.runOperationShowingMessage(gettext('Deleting&hellip;'),
function() {
return $.ajax({
type: 'DELETE',
url:
self.getURLRoot() + "/" +
xblockElement.data('locator') + "?" +
$.param({recurse: true, all_versions: true})
url: self.getURLRoot() + "/" +
xblockElement.data('locator') + "?" +
$.param({recurse: true, all_versions: true})
}).success(function() {
deleting.hide();
xblockElement.remove();
});
}
},
secondary: {
text: gettext('Cancel'),
click: function(prompt) {
return prompt.hide();
}
}
}
}).show();
});
});
},
refreshXBlockElement: function(xblockElement) {
this.refreshXBlock(
new XBlockInfo({
id: xblockElement.data('locator')
}),
xblockElement
);
onNewXBlock: function(xblockElement, scrollOffset, data) {
this.setScrollOffset(xblockElement, scrollOffset);
xblockElement.data('locator', data.locator);
return this.refreshXBlock(xblockElement);
},
refreshXBlock: function(xblockInfo, xblockElement) {
var self = this, temporaryView;
/**
* Refreshes the specified xblock's display. If the xblock is an inline child of a
* reorderable container then the element will be refreshed inline. If not, then the
* parent container will be refreshed instead.
* @param xblockElement The element representing the xblock to be refreshed.
*/
refreshXBlock: function(xblockElement) {
var parentElement = xblockElement.parent(),
rootLocator = this.xblockView.model.id,
xblockLocator = xblockElement.data('locator');
if (xblockLocator === rootLocator) {
this.render();
} else if (parentElement.hasClass('reorderable-container')) {
this.refreshChildXBlock(xblockElement);
} else {
this.refreshXBlock(this.findXBlockElement(parentElement));
}
},
/**
* Refresh an xblock element inline on the page, using the specified xblockInfo.
* Note that the element is removed and replaced with the newly rendered xblock.
* @param xblockElement The xblock element to be refreshed.
* @returns {promise} A promise representing the complete operation.
*/
refreshChildXBlock: function(xblockElement) {
var self = this,
xblockInfo,
TemporaryXBlockView,
temporaryView;
xblockInfo = new XBlockInfo({
id: xblockElement.data('locator')
});
// There is only one Backbone view created on the container page, which is
// for the container xblock itself. Any child xblocks rendered inside the
// container do not get a Backbone view. Thus, create a temporary XBlock
// around the child element so that it can be refreshed.
temporaryView = new XBlockView({
el: xblockElement,
model: xblockInfo,
view: this.view
// container do not get a Backbone view. Thus, create a temporary view
// to render the content, and then replace the original element with the result.
TemporaryXBlockView = XBlockView.extend({
updateHtml: function(element, html) {
// Replace the element with the new HTML content, rather than adding
// it as child elements.
this.$el = $(html).replaceAll(element);
}
});
temporaryView.render({
temporaryView = new TemporaryXBlockView({
model: xblockInfo,
view: 'reorderable_container_child_preview',
el: xblockElement
});
return temporaryView.render({
success: function() {
self.onXBlockRefresh(temporaryView);
temporaryView.unbind(); // Remove the temporary view
self.addButtonActions(xblockElement);
}
});
}
});
return XBlockContainerView;
return XBlockContainerPage;
}); // end define();

View File

@@ -74,12 +74,23 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"],
if (!element) {
element = this.$el;
}
// First render the HTML as the scripts might depend upon it
element.html(html);
// Now asynchronously add the resources to the page
// Render the HTML first as the scripts might depend upon it, and then
// asynchronously add the resources to the page.
this.updateHtml(element, html);
return this.addXBlockFragmentResources(resources);
},
/**
* Updates an element to have the specified HTML. The default method sets the HTML
* as child content, but this can be overridden.
* @param element The element to be updated
* @param html The desired HTML.
*/
updateHtml: function(element, html) {
element.html(html);
},
/**
* Dynamically loads all of an XBlock's dependent resources. This is an asynchronous
* process so a promise is returned.