Display validation messages for any xblock on the container page.

TNL-683
This commit is contained in:
cahrens
2014-10-23 17:21:48 -04:00
parent cb5e90fc08
commit 08ce09bde7
26 changed files with 1146 additions and 337 deletions

View File

@@ -213,6 +213,7 @@ define([
"js/spec/models/component_template_spec",
"js/spec/models/explicit_url_spec",
"js/spec/models/xblock_info_spec",
"js/spec/models/xblock_validation_spec",
"js/spec/utils/drag_and_drop_spec",
"js/spec/utils/handle_iframe_binding_spec",
@@ -228,6 +229,7 @@ define([
"js/spec/views/xblock_spec",
"js/spec/views/xblock_editor_spec",
"js/spec/views/xblock_string_field_editor_spec",
"js/spec/views/xblock_validation_spec",
"js/spec/views/utils/view_utils_spec",

View File

@@ -0,0 +1,46 @@
define(["backbone", "gettext", "underscore"], function (Backbone, gettext, _) {
/**
* Model for xblock validation messages as displayed in Studio.
*/
var XBlockValidationModel = Backbone.Model.extend({
defaults: {
summary: {},
messages: [],
empty: true,
xblock_id: null
},
WARNING : "warning",
ERROR: "error",
NOT_CONFIGURED: "not-configured",
parse: function(response) {
if (!response.empty) {
var summary = "summary" in response ? response.summary : {};
var messages = "messages" in response ? response.messages : [];
if (!(_.has(summary, "text")) || !summary.text) {
summary.text = gettext("This component has validation issues.");
}
if (!(_.has(summary, "type")) || !summary.type) {
summary.type = this.WARNING;
// Possible types are ERROR, WARNING, and NOT_CONFIGURED. NOT_CONFIGURED is treated as a warning.
_.find(messages, function (message) {
if (message.type === this.ERROR) {
summary.type = this.ERROR;
return true;
}
return false;
}, this);
}
response.summary = summary;
if (response.showSummaryOnly) {
messages = [];
}
response.messages = messages;
}
return response;
}
});
return XBlockValidationModel;
});

View File

@@ -0,0 +1,152 @@
define(['js/models/xblock_validation'],
function(XBlockValidationModel) {
var verifyModel;
verifyModel = function(model, expected_empty, expected_summary, expected_messages, expected_xblock_id) {
expect(model.get("empty")).toBe(expected_empty);
expect(model.get("summary")).toEqual(expected_summary);
expect(model.get("messages")).toEqual(expected_messages);
expect(model.get("xblock_id")).toBe(expected_xblock_id);
};
describe('XBlockValidationModel', function() {
it('handles empty variable', function() {
verifyModel(new XBlockValidationModel({parse: true}), true, {}, [], null);
verifyModel(new XBlockValidationModel({"empty": true}, {parse: true}), true, {}, [], null);
// It is assumed that the "empty" state on the JSON object passed in is correct
// (no attempt is made to correct other variables based on empty==true).
verifyModel(
new XBlockValidationModel(
{"empty": true, "messages": [{"text": "Bad JSON case"}], "xblock_id": "id"},
{parse: true}
),
true,
{},
[{"text": "Bad JSON case"}], "id"
);
});
it('creates a summary if not defined', function() {
// Single warning message.
verifyModel(
new XBlockValidationModel({
"empty": false,
"xblock_id": "id"
}, {parse: true}),
false,
{"text": "This component has validation issues.", "type": "warning"},
[],
"id"
);
// Two messages that compute to a "warning" state in the summary.
verifyModel(
new XBlockValidationModel({
"empty": false,
"messages": [{"text": "one", "type": "not-configured"}, {"text": "two", "type": "warning"}],
"xblock_id": "id"
}, {parse: true}),
false,
{"text": "This component has validation issues.", "type": "warning"},
[{"text": "one", "type": "not-configured"}, {"text": "two", "type": "warning"}],
"id"
);
// Two messages, with one of them "error", resulting in an "error" state in the summary.
verifyModel(
new XBlockValidationModel({
"empty": false,
"messages": [{"text": "one", "type": "warning"}, {"text": "two", "type": "error"}],
"xblock_id": "id"
}, {parse: true}),
false,
{"text": "This component has validation issues.", "type": "error"},
[{"text": "one", "type": "warning"}, {"text": "two", "type": "error"}],
"id"
);
});
it('respects summary properties that are defined', function() {
// Summary already present (both text and type), no messages.
verifyModel(
new XBlockValidationModel({
"empty": false,
"xblock_id": "id",
"summary": {"text": "my summary", "type": "custom type"}
}, {parse: true}),
false,
{"text": "my summary", "type": "custom type"},
[],
"id"
);
// Summary text present, but not type (will get default value of warning).
verifyModel(
new XBlockValidationModel({
"empty": false,
"xblock_id": "id",
"summary": {"text": "my summary"}
}, {parse: true}),
false,
{"text": "my summary", "type": "warning"},
[],
"id"
);
// Summary type present, but not text.
verifyModel(
new XBlockValidationModel({
"empty": false,
"summary": {"type": "custom type"},
"messages": [{"text": "one", "type": "not-configured"}, {"text": "two", "type": "warning"}],
"xblock_id": "id"
}, {parse: true}),
false,
{"text": "This component has validation issues.", "type": "custom type"},
[{"text": "one", "type": "not-configured"}, {"text": "two", "type": "warning"}],
"id"
);
// Summary text present, type will be computed as error.
verifyModel(
new XBlockValidationModel({
"empty": false,
"summary": {"text": "my summary"},
"messages": [{"text": "one", "type": "warning"}, {"text": "two", "type": "error"}],
"xblock_id": "id"
}, {parse: true}),
false,
{"text": "my summary", "type": "error"},
[{"text": "one", "type": "warning"}, {"text": "two", "type": "error"}],
"id"
);
});
it('clears messages if showSummaryOnly is true', function() {
verifyModel(
new XBlockValidationModel({
"empty": false,
"xblock_id": "id",
"summary": {"text": "my summary"},
"messages": [{"text": "one", "type": "warning"}, {"text": "two", "type": "error"}],
"showSummaryOnly": true
}, {parse: true}),
false,
{"text": "my summary", "type": "error"},
[],
"id"
);
verifyModel(
new XBlockValidationModel({
"empty": false,
"xblock_id": "id",
"summary": {"text": "my summary"},
"messages": [{"text": "one", "type": "warning"}, {"text": "two", "type": "error"}],
"showSummaryOnly": false
}, {parse: true}),
false,
{"text": "my summary", "type": "error"},
[{"text": "one", "type": "warning"}, {"text": "two", "type": "error"}],
"id"
);
});
});
}
);

View File

@@ -215,7 +215,7 @@ define([
'label': 'label1',
'url': 'url1',
'validation': {
'message': "Warning message",
'text': "Warning message",
'type': 'warning'
}
}
@@ -233,7 +233,7 @@ define([
'label': 'label1',
'url': 'url1',
'validation': {
'message': "Error message",
'text': "Error message",
'type': 'error'
}
}

View File

@@ -102,6 +102,14 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "URI", "js/views/xblock", "
]);
expect(promise.isRejected()).toBe(true);
});
it('Triggers an event to the runtime when a notification-action-button is clicked', function () {
var notifySpy = spyOn(xblockView, "notifyRuntime").andCallThrough();
postXBlockRequest(AjaxHelpers.requests(this), []);
xblockView.$el.find(".notification-action-button").click();
expect(notifySpy).toHaveBeenCalledWith("add-missing-groups", model.get("id"));
})
});
});
});

View File

@@ -0,0 +1,132 @@
define(['jquery', 'js/models/xblock_validation', 'js/views/xblock_validation', 'js/common_helpers/template_helpers'],
function($, XBlockValidationModel, XBlockValidationView, TemplateHelpers) {
beforeEach(function () {
TemplateHelpers.installTemplate('xblock-validation-messages');
});
describe('XBlockValidationView helper methods', function() {
var model, view;
beforeEach(function () {
model = new XBlockValidationModel({parse: true});
view = new XBlockValidationView({model: model});
view.render();
});
it('has a getIcon method', function() {
var getIcon = view.getIcon.bind(view);
expect(getIcon(model.WARNING)).toBe('icon-warning-sign');
expect(getIcon(model.NOT_CONFIGURED)).toBe('icon-warning-sign');
expect(getIcon(model.ERROR)).toBe('icon-exclamation-sign');
expect(getIcon("unknown")).toBeNull();
});
it('has a getDisplayName method', function() {
var getDisplayName = view.getDisplayName.bind(view);
expect(getDisplayName(model.WARNING)).toBe("Warning");
expect(getDisplayName(model.NOT_CONFIGURED)).toBe("Warning");
expect(getDisplayName(model.ERROR)).toBe("Error");
expect(getDisplayName("unknown")).toBeNull();
});
it('can add additional classes', function() {
var noContainerContent = "no-container-content", notConfiguredModel, nonRootView, rootView;
expect(view.getAdditionalClasses()).toBe("");
expect(view.$('.validation')).not.toHaveClass(noContainerContent);
notConfiguredModel = new XBlockValidationModel({
"empty": false, "summary": {"text": "Not configured", "type": model.NOT_CONFIGURED},
"xblock_id": "id"
},
{parse: true}
);
nonRootView = new XBlockValidationView({model: notConfiguredModel});
nonRootView.render();
expect(nonRootView.getAdditionalClasses()).toBe("");
expect(view.$('.validation')).not.toHaveClass(noContainerContent);
rootView = new XBlockValidationView({model: notConfiguredModel, root: true});
rootView.render();
expect(rootView.getAdditionalClasses()).toBe(noContainerContent);
expect(rootView.$('.validation')).toHaveClass(noContainerContent);
});
});
describe('XBlockValidationView rendering', function() {
var model, view;
beforeEach(function () {
model = new XBlockValidationModel({
"empty": false,
"summary": {
"text": "Summary message", "type": "error",
"action_label": "Summary Action", "action_class": "edit-button"
},
"messages": [
{
"text": "First message", "type": "warning",
"action_label": "First Message Action", "action_runtime_event": "fix-up"
},
{"text": "Second message", "type": "error"}
],
"xblock_id": "id"
});
view = new XBlockValidationView({model: model});
view.render();
});
it('renders summary and detailed messages types', function() {
var details;
expect(view.$('.xblock-message')).toHaveClass("has-errors");
details = view.$('.xblock-message-item');
expect(details.length).toBe(2);
expect(details[0]).toHaveClass("warning");
expect(details[1]).toHaveClass("error");
});
it('renders summary and detailed messages text', function() {
var details;
expect(view.$('.xblock-message').text()).toContain("Summary message");
details = view.$('.xblock-message-item');
expect(details.length).toBe(2);
expect($(details[0]).text()).toContain("Warning");
expect($(details[0]).text()).toContain("First message");
expect($(details[1]).text()).toContain("Error");
expect($(details[1]).text()).toContain("Second message");
});
it('renders action info', function() {
expect(view.$('a.edit-button .action-button-text').text()).toContain("Summary Action");
expect(view.$('a.notification-action-button .action-button-text').text()).
toContain("First Message Action");
expect(view.$('a.notification-action-button').data("notification-action")).toBe("fix-up");
});
it('renders a summary only', function() {
var summaryOnlyModel = new XBlockValidationModel({
"empty": false,
"summary": {"text": "Summary message", "type": "warning"},
"xblock_id": "id"
}), summaryOnlyView, details;
summaryOnlyView = new XBlockValidationView({model: summaryOnlyModel});
summaryOnlyView.render();
expect(summaryOnlyView.$('.xblock-message')).toHaveClass("has-warnings");
expect(view.$('.xblock-message').text()).toContain("Summary message");
details = summaryOnlyView.$('.xblock-message-item');
expect(details.length).toBe(0);
});
});
}
);

View File

@@ -13,6 +13,12 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
var XBlockContainerPage = BasePage.extend({
// takes XBlockInfo as a model
events: {
"click .edit-button": "editXBlock",
"click .duplicate-button": "duplicateXBlock",
"click .delete-button": "deleteXBlock"
},
options: {
collapsedClass: 'is-collapsed'
},
@@ -81,12 +87,6 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
// Hide both blocks until we know which one to show
xblockView.$el.addClass(hiddenCss);
if (!options || !options.refresh) {
// Add actions to any top level buttons, e.g. "Edit" of the container itself.
// Do not add the actions on "refresh" though, as the handlers are already registered.
self.addButtonActions(this.$el);
}
// Render the xblock
xblockView.render({
done: function() {
@@ -119,7 +119,6 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
},
onXBlockRefresh: function(xblockView) {
this.addButtonActions(xblockView.$el);
this.xblockView.refresh();
// Update publish and last modified information from the server.
this.model.fetch();
@@ -137,25 +136,12 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
});
},
addButtonActions: function(element) {
var self = this;
element.find('.edit-button').click(function(event) {
event.preventDefault();
self.editComponent(self.findXBlockElement(event.target));
});
element.find('.duplicate-button').click(function(event) {
event.preventDefault();
self.duplicateComponent(self.findXBlockElement(event.target));
});
element.find('.delete-button').click(function(event) {
event.preventDefault();
self.deleteComponent(self.findXBlockElement(event.target));
});
},
editComponent: function(xblockElement) {
var self = this,
editXBlock: function(event) {
var xblockElement = this.findXBlockElement(event.target),
self = this,
modal = new EditXBlockModal({ });
event.preventDefault();
modal.edit(xblockElement, this.model, {
refresh: function() {
self.refreshXBlock(xblockElement);
@@ -163,6 +149,16 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
});
},
duplicateXBlock: function(event) {
event.preventDefault();
this.duplicateComponent(this.findXBlockElement(event.target));
},
deleteXBlock: function(event) {
event.preventDefault();
this.deleteComponent(this.findXBlockElement(event.target));
},
createPlaceholderElement: function() {
return $("<div/>", { class: "studio-xblock-wrapper" });
},

View File

@@ -4,6 +4,10 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"],
var XBlockView = BaseView.extend({
// takes XBlockInfo as a model
events: {
"click .notification-action-button": "fireNotificationActionEvent"
},
initialize: function() {
BaseView.prototype.initialize.call(this);
this.view = this.options.view;
@@ -195,6 +199,14 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"],
}
// Return an already resolved promise for synchronous updates
return $.Deferred().resolve().promise();
},
fireNotificationActionEvent: function(event) {
var eventName = $(event.currentTarget).data("notification-action");
if (eventName) {
event.preventDefault();
this.notifyRuntime(eventName, this.model.get("id"));
}
}
});

View File

@@ -0,0 +1,76 @@
define(["jquery", "underscore", "js/views/baseview", "gettext"],
function ($, _, BaseView, gettext) {
/**
* View for xblock validation messages as displayed in Studio.
*/
var XBlockValidationView = BaseView.extend({
// Takes XBlockValidationModel as a model
initialize: function(options) {
BaseView.prototype.initialize.call(this);
this.template = this.loadTemplate('xblock-validation-messages');
this.root = options.root;
},
render: function () {
this.$el.html(this.template({
validation: this.model,
additionalClasses: this.getAdditionalClasses(),
getIcon: this.getIcon.bind(this),
getDisplayName: this.getDisplayName.bind(this)
}));
return this;
},
/**
* Returns the icon css class based on the message type.
* @param messageType
* @returns string representation of css class that will render the correct icon, or null if unknown type
*/
getIcon: function (messageType) {
if (messageType === this.model.ERROR) {
return 'icon-exclamation-sign';
}
else if (messageType === this.model.WARNING || messageType === this.model.NOT_CONFIGURED) {
return 'icon-warning-sign';
}
return null;
},
/**
* Returns a display name for a message (useful for screen readers), based on the message type.
* @param messageType
* @returns string display name (translated)
*/
getDisplayName: function (messageType) {
if (messageType === this.model.WARNING || messageType === this.model.NOT_CONFIGURED) {
// Translators: This message will be added to the front of messages of type warning,
// e.g. "Warning: this component has not been configured yet".
return gettext("Warning");
}
else if (messageType === this.model.ERROR) {
// Translators: This message will be added to the front of messages of type error,
// e.g. "Error: required field is missing".
return gettext("Error");
}
return null;
},
/**
* Returns additional css classes that can be added to HTML containing the validation messages.
* Useful for rendering NOT_CONFIGURED in a special way.
*
* @returns string of additional css classes (or empty string)
*/
getAdditionalClasses: function () {
if (this.root && this.model.get("summary").type === this.model.NOT_CONFIGURED &&
this.model.get("messages").length === 0) {
return "no-container-content";
}
return "";
}
});
return XBlockValidationView;
});