Commit fd587b97 by Christina Roberts

Merge pull request #5710 from edx/christina/general-component-errors

Support validation messages for any xblock (on container page).
parents f0a37cf4 181715f2
...@@ -7,6 +7,8 @@ the top. Include a label indicating the component affected. ...@@ -7,6 +7,8 @@ the top. Include a label indicating the component affected.
Common: Add configurable reset button to units Common: Add configurable reset button to units
Studio: Add support xblock validation messages on Studio unit/container page. TNL-683
LMS: Support adding cohorts from the instructor dashboard. TNL-162 LMS: Support adding cohorts from the instructor dashboard. TNL-162
LMS: Support adding students to a cohort via the instructor dashboard. TNL-163 LMS: Support adding students to a cohort via the instructor dashboard. TNL-163
......
...@@ -1290,10 +1290,12 @@ class GroupConfiguration(object): ...@@ -1290,10 +1290,12 @@ class GroupConfiguration(object):
'container_handler', 'container_handler',
course.location.course_key.make_usage_key(unit.location.block_type, unit.location.name) course.location.course_key.make_usage_key(unit.location.block_type, unit.location.name)
) )
validation_summary = split_test.general_validation_message()
usage_info[split_test.user_partition_id].append({ usage_info[split_test.user_partition_id].append({
'label': '{} / {}'.format(unit.display_name, split_test.display_name), 'label': '{} / {}'.format(unit.display_name, split_test.display_name),
'url': unit_url, 'url': unit_url,
'validation': split_test.general_validation_message, 'validation': validation_summary.to_json() if validation_summary else None,
}) })
return usage_info return usage_info
......
...@@ -9,7 +9,7 @@ from contentstore.views.course import GroupConfiguration ...@@ -9,7 +9,7 @@ from contentstore.views.course import GroupConfiguration
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
from xmodule.partitions.partitions import Group, UserPartition from xmodule.partitions.partitions import Group, UserPartition
from xmodule.modulestore.tests.factories import ItemFactory from xmodule.modulestore.tests.factories import ItemFactory
from xmodule.split_test_module import ValidationMessage, ValidationMessageType from xmodule.validation import StudioValidation, StudioValidationMessage
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
...@@ -541,87 +541,75 @@ class GroupConfigurationsValidationTestCase(CourseTestCase, HelperMethods): ...@@ -541,87 +541,75 @@ class GroupConfigurationsValidationTestCase(CourseTestCase, HelperMethods):
def setUp(self): def setUp(self):
super(GroupConfigurationsValidationTestCase, self).setUp() super(GroupConfigurationsValidationTestCase, self).setUp()
@patch('xmodule.split_test_module.SplitTestDescriptor.validation_messages') @patch('xmodule.split_test_module.SplitTestDescriptor.validate_split_test')
def test_error_message_present(self, mocked_validation_messages): def verify_validation_add_usage_info(self, expected_result, mocked_message, mocked_validation_messages):
""" """
Tests if validation message is present. Helper method for testing validation information present after add_usage_info.
""" """
self._add_user_partitions() self._add_user_partitions()
split_test = self._create_content_experiment(cid=0, name_suffix='0')[1] split_test = self._create_content_experiment(cid=0, name_suffix='0')[1]
mocked_validation_messages.return_value = [ validation = StudioValidation(split_test.location)
ValidationMessage( validation.add(mocked_message)
split_test, mocked_validation_messages.return_value = validation
u"Validation message",
ValidationMessageType.error
)
]
group_configuration = GroupConfiguration.add_usage_info(self.course, self.store)[0] group_configuration = GroupConfiguration.add_usage_info(self.course, self.store)[0]
self.assertEqual( self.assertEqual(expected_result.to_json(), group_configuration['usage'][0]['validation'])
group_configuration['usage'][0]['validation'],
{
'message': u'This content experiment has issues that affect content visibility.',
'type': 'error'
}
)
@patch('xmodule.split_test_module.SplitTestDescriptor.validation_messages') def test_error_message_present(self):
def test_warning_message_present(self, mocked_validation_messages):
""" """
Tests if validation message is present. Tests if validation message is present (error case).
""" """
self._add_user_partitions() mocked_message = StudioValidationMessage(StudioValidationMessage.ERROR, u"Validation message")
split_test = self._create_content_experiment(cid=0, name_suffix='0')[1] expected_result = StudioValidationMessage(
StudioValidationMessage.ERROR, u"This content experiment has issues that affect content visibility."
)
self.verify_validation_add_usage_info(expected_result, mocked_message) # pylint: disable=no-value-for-parameter
mocked_validation_messages.return_value = [ def test_warning_message_present(self):
ValidationMessage( """
split_test, Tests if validation message is present (warning case).
u"Validation message", """
ValidationMessageType.warning mocked_message = StudioValidationMessage(StudioValidationMessage.WARNING, u"Validation message")
) expected_result = StudioValidationMessage(
] StudioValidationMessage.WARNING, u"This content experiment has issues that affect content visibility."
group_configuration = GroupConfiguration.add_usage_info(self.course, self.store)[0]
self.assertEqual(
group_configuration['usage'][0]['validation'],
{
'message': u'This content experiment has issues that affect content visibility.',
'type': 'warning'
}
) )
self.verify_validation_add_usage_info(expected_result, mocked_message) # pylint: disable=no-value-for-parameter
@patch('xmodule.split_test_module.SplitTestDescriptor.validation_messages') @patch('xmodule.split_test_module.SplitTestDescriptor.validate_split_test')
def test_update_usage_info(self, mocked_validation_messages): def verify_validation_update_usage_info(self, expected_result, mocked_message, mocked_validation_messages):
""" """
Tests if validation message is present when updating usage info. Helper method for testing validation information present after update_usage_info.
""" """
self._add_user_partitions() self._add_user_partitions()
split_test = self._create_content_experiment(cid=0, name_suffix='0')[1] split_test = self._create_content_experiment(cid=0, name_suffix='0')[1]
mocked_validation_messages.return_value = [ validation = StudioValidation(split_test.location)
ValidationMessage( if mocked_message is not None:
split_test, validation.add(mocked_message)
u"Validation message", mocked_validation_messages.return_value = validation
ValidationMessageType.warning
)
]
group_configuration = GroupConfiguration.update_usage_info(self.store, self.course, self.course.user_partitions[0])
group_configuration = GroupConfiguration.update_usage_info(
self.store, self.course, self.course.user_partitions[0]
)
self.assertEqual( self.assertEqual(
group_configuration['usage'][0]['validation'], expected_result.to_json() if expected_result is not None else None,
{ group_configuration['usage'][0]['validation']
'message': u'This content experiment has issues that affect content visibility.',
'type': 'warning'
}
) )
@patch('xmodule.split_test_module.SplitTestDescriptor.validation_messages') def test_update_usage_info(self):
def test_update_usage_info_no_message(self, mocked_validation_messages): """
Tests if validation message is present when updating usage info.
"""
mocked_message = StudioValidationMessage(StudioValidationMessage.WARNING, u"Validation message")
expected_result = StudioValidationMessage(
StudioValidationMessage.WARNING, u"This content experiment has issues that affect content visibility."
)
# pylint: disable=no-value-for-parameter
self.verify_validation_update_usage_info(expected_result, mocked_message)
def test_update_usage_info_no_message(self):
""" """
Tests if validation message is not present when updating usage info. Tests if validation message is not present when updating usage info.
""" """
self._add_user_partitions() self.verify_validation_update_usage_info(None, None) # pylint: disable=no-value-for-parameter
self._create_content_experiment(cid=0, name_suffix='0')
mocked_validation_messages.return_value = []
group_configuration = GroupConfiguration.update_usage_info(self.store, self.course, self.course.user_partitions[0])
self.assertEqual(group_configuration['usage'][0]['validation'], None)
...@@ -130,8 +130,10 @@ class GetItemTest(ItemTest): ...@@ -130,8 +130,10 @@ class GetItemTest(ItemTest):
root_usage_key = self._create_vertical() root_usage_key = self._create_vertical()
html, __ = self._get_container_preview(root_usage_key) html, __ = self._get_container_preview(root_usage_key)
# Verify that the Studio wrapper is not added # XBlock messages are added by the Studio wrapper.
self.assertNotIn('wrapper-xblock', html) self.assertIn('wrapper-xblock-message', html)
# Make sure that "wrapper-xblock" does not appear by itself (without -message at end).
self.assertNotRegexpMatches(html, r'wrapper-xblock[^-]+')
# Verify that the header and article tags are still added # Verify that the header and article tags are still added
self.assertIn('<header class="xblock-header xblock-header-vertical">', html) self.assertIn('<header class="xblock-header xblock-header-vertical">', html)
......
...@@ -213,6 +213,7 @@ define([ ...@@ -213,6 +213,7 @@ define([
"js/spec/models/component_template_spec", "js/spec/models/component_template_spec",
"js/spec/models/explicit_url_spec", "js/spec/models/explicit_url_spec",
"js/spec/models/xblock_info_spec", "js/spec/models/xblock_info_spec",
"js/spec/models/xblock_validation_spec",
"js/spec/utils/drag_and_drop_spec", "js/spec/utils/drag_and_drop_spec",
"js/spec/utils/handle_iframe_binding_spec", "js/spec/utils/handle_iframe_binding_spec",
...@@ -228,6 +229,7 @@ define([ ...@@ -228,6 +229,7 @@ define([
"js/spec/views/xblock_spec", "js/spec/views/xblock_spec",
"js/spec/views/xblock_editor_spec", "js/spec/views/xblock_editor_spec",
"js/spec/views/xblock_string_field_editor_spec", "js/spec/views/xblock_string_field_editor_spec",
"js/spec/views/xblock_validation_spec",
"js/spec/views/utils/view_utils_spec", "js/spec/views/utils/view_utils_spec",
......
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;
});
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"
);
});
});
}
);
...@@ -215,7 +215,7 @@ define([ ...@@ -215,7 +215,7 @@ define([
'label': 'label1', 'label': 'label1',
'url': 'url1', 'url': 'url1',
'validation': { 'validation': {
'message': "Warning message", 'text': "Warning message",
'type': 'warning' 'type': 'warning'
} }
} }
...@@ -233,7 +233,7 @@ define([ ...@@ -233,7 +233,7 @@ define([
'label': 'label1', 'label': 'label1',
'url': 'url1', 'url': 'url1',
'validation': { 'validation': {
'message': "Error message", 'text': "Error message",
'type': 'error' 'type': 'error'
} }
} }
......
...@@ -102,6 +102,14 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "URI", "js/views/xblock", " ...@@ -102,6 +102,14 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "URI", "js/views/xblock", "
]); ]);
expect(promise.isRejected()).toBe(true); 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"));
})
}); });
}); });
}); });
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);
});
});
}
);
...@@ -13,6 +13,12 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -13,6 +13,12 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
var XBlockContainerPage = BasePage.extend({ var XBlockContainerPage = BasePage.extend({
// takes XBlockInfo as a model // takes XBlockInfo as a model
events: {
"click .edit-button": "editXBlock",
"click .duplicate-button": "duplicateXBlock",
"click .delete-button": "deleteXBlock"
},
options: { options: {
collapsedClass: 'is-collapsed' collapsedClass: 'is-collapsed'
}, },
...@@ -81,12 +87,6 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -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 // Hide both blocks until we know which one to show
xblockView.$el.addClass(hiddenCss); 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 // Render the xblock
xblockView.render({ xblockView.render({
done: function() { done: function() {
...@@ -119,7 +119,6 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -119,7 +119,6 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
}, },
onXBlockRefresh: function(xblockView) { onXBlockRefresh: function(xblockView) {
this.addButtonActions(xblockView.$el);
this.xblockView.refresh(); this.xblockView.refresh();
// Update publish and last modified information from the server. // Update publish and last modified information from the server.
this.model.fetch(); this.model.fetch();
...@@ -137,25 +136,12 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -137,25 +136,12 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
}); });
}, },
addButtonActions: function(element) { editXBlock: function(event) {
var self = this; var xblockElement = this.findXBlockElement(event.target),
element.find('.edit-button').click(function(event) { self = this,
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,
modal = new EditXBlockModal({ }); modal = new EditXBlockModal({ });
event.preventDefault();
modal.edit(xblockElement, this.model, { modal.edit(xblockElement, this.model, {
refresh: function() { refresh: function() {
self.refreshXBlock(xblockElement); self.refreshXBlock(xblockElement);
...@@ -163,6 +149,16 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -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() { createPlaceholderElement: function() {
return $("<div/>", { class: "studio-xblock-wrapper" }); return $("<div/>", { class: "studio-xblock-wrapper" });
}, },
......
...@@ -4,6 +4,10 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"], ...@@ -4,6 +4,10 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"],
var XBlockView = BaseView.extend({ var XBlockView = BaseView.extend({
// takes XBlockInfo as a model // takes XBlockInfo as a model
events: {
"click .notification-action-button": "fireNotificationActionEvent"
},
initialize: function() { initialize: function() {
BaseView.prototype.initialize.call(this); BaseView.prototype.initialize.call(this);
this.view = this.options.view; this.view = this.options.view;
...@@ -195,6 +199,14 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"], ...@@ -195,6 +199,14 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"],
} }
// Return an already resolved promise for synchronous updates // Return an already resolved promise for synchronous updates
return $.Deferred().resolve().promise(); 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"));
}
} }
}); });
......
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;
});
...@@ -124,6 +124,23 @@ div.wrapper-comp-editor.is-inactive ~ div.launch-latex-compiler{ ...@@ -124,6 +124,23 @@ div.wrapper-comp-editor.is-inactive ~ div.launch-latex-compiler{
// ==================== // ====================
// xblock message UI override
// (to fake the Studio no-content pattern design since validation classes cannot be removed in logic layer)
.wrapper-xblock .xblock-message.no-container-content.xblock-message.no-container-content {
border: 0;
padding: ($baseline*1.5) ($baseline*2);
background-color: $gray-l4;
text-align: center;
color: $gray;
.button,
.action {
@extend %btn-primary-green;
}
}
// ====================
// TODOs: // TODOs:
// * font-weight syncing // * font-weight syncing
......
...@@ -11,8 +11,8 @@ ...@@ -11,8 +11,8 @@
background: $white; background: $white;
box-shadow: 0px 1px 1px $shadow-l1; box-shadow: 0px 1px 1px $shadow-l1;
// STATE: hover/focus
&:hover { &:hover, &:focus {
box-shadow: 0 0 1px $shadow; box-shadow: 0 0 1px $shadow;
} }
...@@ -165,7 +165,7 @@ ...@@ -165,7 +165,7 @@
.xblock-message { .xblock-message {
@extend %t-copy-sub1; @extend %t-copy-sub1;
background-color: $gray-d2; background-color: $gray-d2;
padding: ($baseline/2) ($baseline*.75); padding: ($baseline*0.75);
color: $white; color: $white;
[class^="icon-"] { [class^="icon-"] {
...@@ -187,7 +187,7 @@ ...@@ -187,7 +187,7 @@
} }
&.has-warnings { &.has-warnings {
border-bottom: 3px solid $orange; border-top: 3px solid $orange;
.icon-warning-sign { .icon-warning-sign {
margin-right: ($baseline/2); margin-right: ($baseline/2);
...@@ -196,7 +196,7 @@ ...@@ -196,7 +196,7 @@
} }
&.has-errors { &.has-errors {
border-bottom: 3px solid $red-l2; border-top: 3px solid $red-l2;
.icon-exclamation-sign { .icon-exclamation-sign {
margin-right: ($baseline/2); margin-right: ($baseline/2);
...@@ -231,14 +231,13 @@ ...@@ -231,14 +231,13 @@
} }
.xblock-message { .xblock-message {
border-radius: 3px 3px 0 0;
&.validation { &.validation {
padding-top: ($baseline*.75); padding-top: ($baseline*0.75);
} }
.xblock-message-list { .xblock-message-list {
margin: ($baseline/5) ($baseline*2.5); margin: 0 ($baseline*2.25);
list-style-type: disc; list-style-type: disc;
color: $gray-l3; color: $gray-l3;
} }
...@@ -248,7 +247,7 @@ ...@@ -248,7 +247,7 @@
} }
&.information { &.information {
padding: 0 0 ($baseline/2) 0; padding: ($baseline/2) 0;
background-color: $gray-l5; background-color: $gray-l5;
color: $gray-d1; color: $gray-d1;
} }
...@@ -342,28 +341,12 @@ ...@@ -342,28 +341,12 @@
.wrapper-xblock-message { .wrapper-xblock-message {
.xblock-message { .xblock-message {
border-radius: 0 0 3px 3px;
.xblock-message-list {
margin: 0;
list-style-type: none;
}
&.information { &.information {
@extend %t-copy-sub2; @extend %t-copy-sub2;
padding: 0 0 ($baseline/2) $baseline; padding: 0 $baseline ($baseline*0.75) $baseline;
color: $gray-l1; color: $gray-l1;
} }
&.validation.has-warnings {
border: 0;
border-top: 3px solid $orange;
}
&.validation.has-errors {
border: 0;
border-top: 3px solid $red-l2;
}
} }
} }
} }
......
...@@ -71,7 +71,7 @@ ...@@ -71,7 +71,7 @@
<i class="icon-exclamation-sign"></i> <i class="icon-exclamation-sign"></i>
<% } %> <% } %>
<span class="group-configuration-validation-message"> <span class="group-configuration-validation-message">
<%= unit.validation.message %> <%= unit.validation.text %>
</span> </span>
</p> </p>
<% } %> <% } %>
......
...@@ -9,6 +9,9 @@ ...@@ -9,6 +9,9 @@
<span data-tooltip="Drag to reorder" class="drag-handle action"></span> <span data-tooltip="Drag to reorder" class="drag-handle action"></span>
</li> </li>
</ul> </ul>
<a href="#" class="button action-button notification-action-button" data-notification-action="add-missing-groups">
<span class="action-button-text">Add Missing Groups</span>
</a>
</div> </div>
</header> </header>
<article class="xblock-render"> <article class="xblock-render">
......
<%
var summaryMessage = validation.get("summary");
var aggregateMessageType = summaryMessage.type;
var aggregateValidationClass = aggregateMessageType === "error"? "has-errors" : "has-warnings";
%>
<div class="xblock-message validation <%= aggregateValidationClass %> <%= additionalClasses %>">
<p class="<%= aggregateMessageType %>"><i class="<%= getIcon(aggregateMessageType) %>"></i>
<%- summaryMessage.text %>
<% if (summaryMessage.action_class) { %>
<a href="#" class="button action-button <%- summaryMessage.action_class %>">
<span class="action-button-text"><%- summaryMessage.action_label %></span>
</a>
<% } else if (summaryMessage.action_runtime_event) {%>
<a href="#" class="button action-button notification-action-button" data-notification-action="<%- summaryMessage.action_runtime_event %>">
<span class="action-button-text"><%- summaryMessage.action_label %></span>
</a>
<% } %>
</p>
<% var detailedMessages = validation.get("messages"); %>
<% if (detailedMessages.length > 0) { %>
<ul class="xblock-message-list">
<% for (var i = 0; i < detailedMessages.length; i++) { %>
<%
var message = detailedMessages[i];
var messageType = message.type
var messageTypeDisplayName = getDisplayName(messageType)
%>
<li class="xblock-message-item <%= messageType %>">
<span class="message-text">
<% if (messageTypeDisplayName) { %>
<span class="sr"><%- messageTypeDisplayName %>:</span>
<% } %>
<%- message.text %>
<% if (message.action_class) { %>
<a href="#" class="button action-button <%- message.action_class %>">
<span class="action-button-text"><%- message.action_label %></span>
</a>
<% } else if (message.action_runtime_event) {%>
<a href="#" class="button action-button notification-action-button" data-notification-action="<%- message.action_runtime_event %>">
<span class="action-button-text"><%- message.action_label %></span>
</a>
<% } %>
</span>
</li>
<% } %>
</ul>
<% } %>
</div>
<%! <%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from contentstore.views.helpers import xblock_studio_url from contentstore.views.helpers import xblock_studio_url
import json
%> %>
<% <%
xblock_url = xblock_studio_url(xblock) xblock_url = xblock_studio_url(xblock)
show_inline = xblock.has_children and not xblock_url show_inline = xblock.has_children and not xblock_url
section_class = "level-nesting" if show_inline else "level-element" section_class = "level-nesting" if show_inline else "level-element"
collapsible_class = "is-collapsible" if xblock.has_children else "" collapsible_class = "is-collapsible" if xblock.has_children else ""
label = xblock.display_name or xblock.scope_ids.block_type label = xblock.display_name or xblock.scope_ids.block_type
messages = json.dumps(xblock.validate().to_json())
%> %>
<%namespace name='static' file='static_content.html'/>
<%block name="header_extras">
<script type="text/template" id="xblock-validation-messages-tpl">
<%static:include path="js/xblock-validation-messages.underscore" />
</script>
</%block>
<script type='text/javascript'>
require(["js/views/xblock_validation", "js/models/xblock_validation"],
function (XBlockValidationView, XBlockValidationModel) {
var validationMessages = ${messages};
% if xblock_url and not is_root:
validationMessages.showSummaryOnly = true;
% endif
var model = new XBlockValidationModel(validationMessages, {parse: true});
if (!model.get("empty")) {
var validationEle = $('div.xblock-validation-messages[data-locator="${xblock.location | h}"]');
var viewOptions = {
el: validationEle,
model: model
};
% if is_root:
viewOptions.root = true;
% endif
var view = new XBlockValidationView(viewOptions);
view.render();
}
});
</script>
% if not is_root: % if not is_root:
% if is_reorderable: % if is_reorderable:
<li class="studio-xblock-wrapper is-draggable" data-locator="${xblock.location | h}" data-course-key="${xblock.location.course_key | h}"> <li class="studio-xblock-wrapper is-draggable" data-locator="${xblock.location | h}" data-course-key="${xblock.location.course_key | h}">
...@@ -64,21 +99,29 @@ label = xblock.display_name or xblock.scope_ids.block_type ...@@ -64,21 +99,29 @@ label = xblock.display_name or xblock.scope_ids.block_type
</ul> </ul>
</div> </div>
</div> </div>
% if xblock_url and not is_root: % if not is_root:
<div class="xblock-header-secondary"> <div class="wrapper-xblock-message xblock-validation-messages" data-locator="${xblock.location | h}"/>
<div class="meta-info">${_('This block contains multiple components.')}</div> % if xblock_url:
<ul class="actions-list"> <div class="xblock-header-secondary">
<li class="action-item action-view"> <div class="meta-info">${_('This block contains multiple components.')}</div>
<a href="${xblock_url}" class="action-button"> <ul class="actions-list">
## Translators: this is a verb describing the action of viewing more details <li class="action-item action-view">
<span class="action-button-text">${_('View')}</span> <a href="${xblock_url}" class="action-button">
<i class="icon-arrow-right"></i> ## Translators: this is a verb describing the action of viewing more details
</a> <span class="action-button-text">${_('View')}</span>
</li> <i class="icon-arrow-right"></i>
</ul> </a>
</div> </li>
</ul>
</div>
% endif
% endif % endif
</header> </header>
% if is_root:
<div class="wrapper-xblock-message xblock-validation-messages" data-locator="${xblock.location | h}"/>
% endif
% if is_root or not xblock_url: % if is_root or not xblock_url:
<article class="xblock-render"> <article class="xblock-render">
${content} ${content}
...@@ -86,7 +129,6 @@ label = xblock.display_name or xblock.scope_ids.block_type ...@@ -86,7 +129,6 @@ label = xblock.display_name or xblock.scope_ids.block_type
% else: % else:
<div class="xblock-message-area"> <div class="xblock-message-area">
${content} ${content}
</div>
% endif % endif
% if not is_root: % if not is_root:
......
/* JavaScript for editing operations that can be done on the split test author view. */ /* JavaScript for editing operations that can be done on the split test author view. */
window.SplitTestAuthorView = function (runtime, element) { window.SplitTestAuthorView = function (runtime, element) {
var $element = $(element); var $element = $(element);
var splitTestLocator = $element.closest('.studio-xblock-wrapper').data('locator');
$element.find('.add-missing-groups-button').click(function () { runtime.listenTo("add-missing-groups", function (parentLocator) {
runtime.notify('save', { if (splitTestLocator === parentLocator) {
state: 'start',
element: element,
message: gettext('Creating missing groups&hellip;')
});
$.post(runtime.handlerUrl(element, 'add_missing_groups')).done(function() {
runtime.notify('save', { runtime.notify('save', {
state: 'end', state: 'start',
element: element element: element,
message: gettext('Creating missing groups&hellip;')
}); });
}); $.post(runtime.handlerUrl(element, 'add_missing_groups')).done(function() {
runtime.notify('save', {
state: 'end',
element: element
});
});
}
}); });
// Listen to delete events so that the view can refresh when the last inactive group is removed. // Listen to delete events so that the view can refresh when the last inactive group is removed.
runtime.listenTo('deleted-child', function(parentLocator) { runtime.listenTo('deleted-child', function(parentLocator) {
var splitTestLocator = $element.closest('.studio-xblock-wrapper').data('locator'), var inactiveGroups = $element.find('.is-inactive .studio-xblock-wrapper');
inactiveGroups = $element.find('.is-inactive .studio-xblock-wrapper');
if (splitTestLocator === parentLocator && inactiveGroups.length === 0) { if (splitTestLocator === parentLocator && inactiveGroups.length === 0) {
runtime.refreshXBlock($element); runtime.refreshXBlock($element);
} }
......
"""
Test xblock/validation.py
"""
import unittest
from xblock.test.tools import assert_raises
from xmodule.validation import StudioValidationMessage, StudioValidation
from xblock.validation import Validation, ValidationMessage
class StudioValidationMessageTest(unittest.TestCase):
"""
Tests for `ValidationMessage`
"""
def test_bad_parameters(self):
"""
Test that `TypeError`s are thrown for bad input parameters.
"""
with assert_raises(TypeError):
StudioValidationMessage("unknown type", u"Unknown type info")
with assert_raises(TypeError):
StudioValidationMessage(StudioValidationMessage.WARNING, u"bad warning", action_class=0)
with assert_raises(TypeError):
StudioValidationMessage(StudioValidationMessage.WARNING, u"bad warning", action_runtime_event=0)
with assert_raises(TypeError):
StudioValidationMessage(StudioValidationMessage.WARNING, u"bad warning", action_label="Non-unicode string")
def test_to_json(self):
"""
Test the `to_json` method.
"""
self.assertEqual(
{
"type": StudioValidationMessage.NOT_CONFIGURED,
"text": u"Not Configured message",
"action_label": u"Action label"
},
StudioValidationMessage(
StudioValidationMessage.NOT_CONFIGURED, u"Not Configured message", action_label=u"Action label"
).to_json()
)
self.assertEqual(
{
"type": StudioValidationMessage.WARNING,
"text": u"Warning message",
"action_class": "class-for-action"
},
StudioValidationMessage(
StudioValidationMessage.WARNING, u"Warning message", action_class="class-for-action"
).to_json()
)
self.assertEqual(
{
"type": StudioValidationMessage.ERROR,
"text": u"Error message",
"action_runtime_event": "do-fix-up"
},
StudioValidationMessage(
StudioValidationMessage.ERROR, u"Error message", action_runtime_event="do-fix-up"
).to_json()
)
class StudioValidationTest(unittest.TestCase):
"""
Tests for `StudioValidation` class.
"""
def test_copy(self):
validation = Validation("id")
validation.add(ValidationMessage(ValidationMessage.ERROR, u"Error message"))
studio_validation = StudioValidation.copy(validation)
self.assertIsInstance(studio_validation, StudioValidation)
self.assertFalse(studio_validation)
self.assertEqual(1, len(studio_validation.messages))
expected = {
"type": StudioValidationMessage.ERROR,
"text": u"Error message"
}
self.assertEqual(expected, studio_validation.messages[0].to_json())
self.assertIsNone(studio_validation.summary)
def test_copy_studio_validation(self):
validation = StudioValidation("id")
validation.add(
StudioValidationMessage(StudioValidationMessage.WARNING, u"Warning message", action_label=u"Action Label")
)
validation_copy = StudioValidation.copy(validation)
self.assertFalse(validation_copy)
self.assertEqual(1, len(validation_copy.messages))
expected = {
"type": StudioValidationMessage.WARNING,
"text": u"Warning message",
"action_label": u"Action Label"
}
self.assertEqual(expected, validation_copy.messages[0].to_json())
def test_copy_errors(self):
with assert_raises(TypeError):
StudioValidation.copy("foo")
def test_empty(self):
"""
Test that `empty` return True iff there are no messages and no summary.
Also test the "bool" property of `Validation`.
"""
validation = StudioValidation("id")
self.assertTrue(validation.empty)
self.assertTrue(validation)
validation.add(StudioValidationMessage(StudioValidationMessage.ERROR, u"Error message"))
self.assertFalse(validation.empty)
self.assertFalse(validation)
validation_with_summary = StudioValidation("id")
validation_with_summary.set_summary(
StudioValidationMessage(StudioValidationMessage.NOT_CONFIGURED, u"Summary message")
)
self.assertFalse(validation.empty)
self.assertFalse(validation)
def test_add_messages(self):
"""
Test the behavior of calling `add_messages` with combination of `StudioValidation` instances.
"""
validation_1 = StudioValidation("id")
validation_1.set_summary(StudioValidationMessage(StudioValidationMessage.WARNING, u"Summary message"))
validation_1.add(StudioValidationMessage(StudioValidationMessage.ERROR, u"Error message"))
validation_2 = StudioValidation("id")
validation_2.set_summary(StudioValidationMessage(StudioValidationMessage.ERROR, u"Summary 2 message"))
validation_2.add(StudioValidationMessage(StudioValidationMessage.NOT_CONFIGURED, u"Not configured"))
validation_1.add_messages(validation_2)
self.assertEqual(2, len(validation_1.messages))
self.assertEqual(StudioValidationMessage.ERROR, validation_1.messages[0].type)
self.assertEqual(u"Error message", validation_1.messages[0].text)
self.assertEqual(StudioValidationMessage.NOT_CONFIGURED, validation_1.messages[1].type)
self.assertEqual(u"Not configured", validation_1.messages[1].text)
self.assertEqual(StudioValidationMessage.WARNING, validation_1.summary.type)
self.assertEqual(u"Summary message", validation_1.summary.text)
def test_set_summary_accepts_validation_message(self):
"""
Test that `set_summary` accepts a ValidationMessage.
"""
validation = StudioValidation("id")
validation.set_summary(ValidationMessage(ValidationMessage.WARNING, u"Summary message"))
self.assertEqual(ValidationMessage.WARNING, validation.summary.type)
self.assertEqual(u"Summary message", validation.summary.text)
def test_set_summary_errors(self):
"""
Test that `set_summary` errors if argument is not a ValidationMessage.
"""
with assert_raises(TypeError):
StudioValidation("id").set_summary("foo")
def test_to_json(self):
"""
Test the ability to serialize a `StudioValidation` instance.
"""
validation = StudioValidation("id")
expected = {
"xblock_id": "id",
"messages": [],
"empty": True
}
self.assertEqual(expected, validation.to_json())
validation.add(
StudioValidationMessage(
StudioValidationMessage.ERROR,
u"Error message",
action_label=u"Action label",
action_class="edit-button"
)
)
validation.add(
StudioValidationMessage(
StudioValidationMessage.NOT_CONFIGURED,
u"Not configured message",
action_label=u"Action label",
action_runtime_event="make groups"
)
)
validation.set_summary(
StudioValidationMessage(
StudioValidationMessage.WARNING,
u"Summary message",
action_label=u"Summary label",
action_runtime_event="fix everything"
)
)
# Note: it is important to test all the expected strings here because the client-side model depends on them
# (for instance, "warning" vs. using the xblock constant ValidationMessageTypes.WARNING).
expected = {
"xblock_id": "id",
"messages": [
{"type": "error", "text": u"Error message", "action_label": u"Action label", "action_class": "edit-button"},
{"type": "not-configured", "text": u"Not configured message", "action_label": u"Action label", "action_runtime_event": "make groups"}
],
"summary": {"type": "warning", "text": u"Summary message", "action_label": u"Summary label", "action_runtime_event": "fix everything"},
"empty": False
}
self.assertEqual(expected, validation.to_json())
"""
Extension of XBlock Validation class to include information for presentation in Studio.
"""
from xblock.validation import Validation, ValidationMessage
class StudioValidationMessage(ValidationMessage):
"""
A message containing validation information about an xblock, extended to provide Studio-specific fields.
"""
# A special message type indicating that the xblock is not yet configured. This message may be rendered
# in a different way within Studio.
NOT_CONFIGURED = "not-configured"
TYPES = [ValidationMessage.WARNING, ValidationMessage.ERROR, NOT_CONFIGURED]
def __init__(self, message_type, message_text, action_label=None, action_class=None, action_runtime_event=None):
"""
Create a new message.
Args:
message_type (str): The type associated with this message. Most be `WARNING` or `ERROR`.
message_text (unicode): The textual message.
action_label (unicode): Text to show on a "fix-up" action (optional). If present, either `action_class`
or `action_runtime_event` should be specified.
action_class (str): A class to link to the "fix-up" action (optional). A click handler must be added
for this class, unless it is "edit-button", "duplicate-button", or "delete-button" (which are all
handled in general for xblock instances.
action_runtime_event (str): An event name to be triggered on the xblock client-side runtime when
the "fix-up" action is clicked (optional).
"""
super(StudioValidationMessage, self).__init__(message_type, message_text)
if action_label is not None:
if not isinstance(action_label, unicode):
raise TypeError("Action label must be unicode.")
self.action_label = action_label
if action_class is not None:
if not isinstance(action_class, basestring):
raise TypeError("Action class must be a string.")
self.action_class = action_class
if action_runtime_event is not None:
if not isinstance(action_runtime_event, basestring):
raise TypeError("Action runtime event must be a string.")
self.action_runtime_event = action_runtime_event
def to_json(self):
"""
Convert to a json-serializable representation.
Returns:
dict: A dict representation that is json-serializable.
"""
serialized = super(StudioValidationMessage, self).to_json()
if hasattr(self, "action_label"):
serialized["action_label"] = self.action_label
if hasattr(self, "action_class"):
serialized["action_class"] = self.action_class
if hasattr(self, "action_runtime_event"):
serialized["action_runtime_event"] = self.action_runtime_event
return serialized
class StudioValidation(Validation):
"""
Extends `Validation` to add Studio-specific summary message.
"""
@classmethod
def copy(cls, validation):
"""
Copies the `Validation` object to a `StudioValidation` object. This is a shallow copy.
Args:
validation (Validation): A `Validation` object to be converted to a `StudioValidation` instance.
Returns:
StudioValidation: A `StudioValidation` instance populated with the messages from supplied
`Validation` object
"""
if not isinstance(validation, Validation):
raise TypeError("Copy must be called with a Validation instance")
studio_validation = cls(validation.xblock_id)
studio_validation.messages = validation.messages
return studio_validation
def __init__(self, xblock_id):
"""
Create a `StudioValidation` instance.
Args:
xblock_id (object): An identification object that must support conversion to unicode.
"""
super(StudioValidation, self).__init__(xblock_id)
self.summary = None
def set_summary(self, message):
"""
Sets a summary message on this instance. The summary is optional.
Args:
message (ValidationMessage): A validation message to set as this instance's summary.
"""
if not isinstance(message, ValidationMessage):
raise TypeError("Argument must of type ValidationMessage")
self.summary = message
@property
def empty(self):
"""
Is this object empty (contains no messages and no summary)?
Returns:
bool: True iff this instance has no validation issues and therefore has no messages or summary.
"""
return super(StudioValidation, self).empty and not self.summary
def to_json(self):
"""
Convert to a json-serializable representation.
Returns:
dict: A dict representation that is json-serializable.
"""
serialized = super(StudioValidation, self).to_json()
if self.summary:
serialized["summary"] = self.summary.to_json()
return serialized
...@@ -16,6 +16,7 @@ class ContainerPage(PageObject): ...@@ -16,6 +16,7 @@ class ContainerPage(PageObject):
NAME_SELECTOR = '.page-header-title' NAME_SELECTOR = '.page-header-title'
NAME_INPUT_SELECTOR = '.page-header .xblock-field-input' NAME_INPUT_SELECTOR = '.page-header .xblock-field-input'
NAME_FIELD_WRAPPER_SELECTOR = '.page-header .wrapper-xblock-field' NAME_FIELD_WRAPPER_SELECTOR = '.page-header .wrapper-xblock-field'
ADD_MISSING_GROUPS_SELECTOR = '.notification-action-button[data-notification-action="add-missing-groups"]'
def __init__(self, browser, locator): def __init__(self, browser, locator):
super(ContainerPage, self).__init__(browser) super(ContainerPage, self).__init__(browser)
...@@ -246,7 +247,7 @@ class ContainerPage(PageObject): ...@@ -246,7 +247,7 @@ class ContainerPage(PageObject):
Click the "add missing groups" link. Click the "add missing groups" link.
Note that this does an ajax call. Note that this does an ajax call.
""" """
self.q(css='.add-missing-groups-button').first.click() self.q(css=self.ADD_MISSING_GROUPS_SELECTOR).first.click()
self.wait_for_ajax() self.wait_for_ajax()
# Wait until all xblocks rendered. # Wait until all xblocks rendered.
...@@ -256,7 +257,7 @@ class ContainerPage(PageObject): ...@@ -256,7 +257,7 @@ class ContainerPage(PageObject):
""" """
Returns True if the "add missing groups" button is present. Returns True if the "add missing groups" button is present.
""" """
return self.q(css='.add-missing-groups-button').present return self.q(css=self.ADD_MISSING_GROUPS_SELECTOR).present
def get_xblock_information_message(self): def get_xblock_information_message(self):
""" """
......
...@@ -18,7 +18,7 @@ from courseware.tests.factories import StudentPrefsFactory, StudentInfoFactory ...@@ -18,7 +18,7 @@ from courseware.tests.factories import StudentPrefsFactory, StudentInfoFactory
from xblock.fields import Scope, BlockScope, ScopeIds from xblock.fields import Scope, BlockScope, ScopeIds
from django.test import TestCase from django.test import TestCase
from django.db import DatabaseError from django.db import DatabaseError
from xblock.core import KeyValueMultiSaveError from xblock.exceptions import KeyValueMultiSaveError
def mock_field(scope, name): def mock_field(scope, name):
......
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<%! from xmodule.split_test_module import ValidationMessageType %>
<% <%
split_test = context.get('split_test') split_test = context.get('split_test')
user_partition = split_test.descriptor.get_selected_partition() user_partition = split_test.descriptor.get_selected_partition()
messages = split_test.descriptor.validation_messages()
show_link = group_configuration_url is not None show_link = group_configuration_url is not None
%> %>
% if is_root and not is_configured: % if is_configured:
<div class="no-container-content"> <div class="wrapper-xblock-message">
% else:
<div class="wrapper-xblock-message">
% endif
% if user_partition:
<div class="xblock-message information"> <div class="xblock-message information">
<p> <p>
<span class="message-text"> <span class="message-text">
...@@ -24,56 +17,8 @@ show_link = group_configuration_url is not None ...@@ -24,56 +17,8 @@ show_link = group_configuration_url is not None
</span> </span>
</p> </p>
</div> </div>
% endif
% if len(messages) > 0:
<%
general_validation = split_test.descriptor.general_validation_message
def get_validation_icon(validation_type):
if validation_type == ValidationMessageType.error:
return 'icon-exclamation-sign'
elif validation_type == ValidationMessageType.warning:
return 'icon-warning-sign'
return None
aggregate_validation_class = 'has-errors' if general_validation['type']==ValidationMessageType.error else ' has-warnings'
%>
<div class="xblock-message validation ${aggregate_validation_class}">
% if is_configured:
<p class="${general_validation['type']}"><i class="${get_validation_icon(general_validation['type'])}"></i>
${general_validation['message']}
</p>
% endif
% if is_root or not is_configured:
<ul class="xblock-message-list">
% for message in messages:
<%
message_type = message.message_type
message_type_display_name = ValidationMessageType.display_name(message_type) if message_type else None
%>
<li class="xblock-message-item ${message_type}">
% if not is_configured:
<i class="${get_validation_icon(message_type)}"></i>
% endif
<span class="message-text">
% if message_type_display_name:
<span class="sr">${message_type_display_name}:</span>
% endif
${unicode(message)}
% if message.action_class:
<a href="#" class="button action-button ${message.action_class}">
<span class="action-button-text">${message.action_label}</span>
</a>
% endif
</span>
</li>
% endfor
</ul>
% endif
</div>
% endif
</div> </div>
% endif
% if is_root: % if is_root:
<div class="wrapper-groups is-active"> <div class="wrapper-groups is-active">
......
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
-e git+https://github.com/pmitros/django-pyfs.git@d175715e0fe3367ec0f1ee429c242d603f6e8b10#egg=djpyfs -e git+https://github.com/pmitros/django-pyfs.git@d175715e0fe3367ec0f1ee429c242d603f6e8b10#egg=djpyfs
# Our libraries: # Our libraries:
-e git+https://github.com/edx/XBlock.git@246811773c67a84fdb17614a8e9f7ec7b1890574#egg=XBlock -e git+https://github.com/edx/XBlock.git@2029af2a4b524310847decfb34ef39da8a30dc4e#egg=XBlock
-e git+https://github.com/edx/codejail.git@66dd5a45e5072666ff9a70c768576e9ffd1daa4b#egg=codejail -e git+https://github.com/edx/codejail.git@66dd5a45e5072666ff9a70c768576e9ffd1daa4b#egg=codejail
-e git+https://github.com/edx/diff-cover.git@v0.7.1#egg=diff_cover -e git+https://github.com/edx/diff-cover.git@v0.7.1#egg=diff_cover
-e git+https://github.com/edx/js-test-tool.git@v0.1.5#egg=js_test_tool -e git+https://github.com/edx/js-test-tool.git@v0.1.5#egg=js_test_tool
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment