Commit d38b51cb by polesye Committed by Tim Babych

BLD-1117: Add read-only list of Group Configurations.

parent 35152121
......@@ -43,6 +43,7 @@ log = logging.getLogger(__name__)
# NOTE: unit_handler assumes this list is disjoint from ADVANCED_COMPONENT_TYPES
COMPONENT_TYPES = ['discussion', 'html', 'problem', 'video']
SPLIT_TEST_COMPONENT_TYPE = 'split_test'
OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"]
NOTE_COMPONENT_TYPES = ['notes']
......@@ -61,11 +62,11 @@ else:
# XBlocks from pmitros repos are prototypes. They should not be used
# except for edX Learning Sciences experiments on edge.edx.org without
# further work to make them robust, maintainable, finalize data formats,
# etc.
# etc.
'concept', # Concept mapper. See https://github.com/pmitros/ConceptXBlock
'done', # Lets students mark things as done. See https://github.com/pmitros/DoneXBlock
'audio', # Embed an audio file. See https://github.com/pmitros/AudioXBlock
'split_test'
SPLIT_TEST_COMPONENT_TYPE, # Adds A/B test support
] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES
ADVANCED_COMPONENT_CATEGORY = 'advanced'
......
......@@ -44,7 +44,8 @@ from .access import has_course_access
from .component import (
OPEN_ENDED_COMPONENT_TYPES,
NOTE_COMPONENT_TYPES,
ADVANCED_COMPONENT_POLICY_KEY
ADVANCED_COMPONENT_POLICY_KEY,
SPLIT_TEST_COMPONENT_TYPE,
)
from django_comment_common.models import assign_default_role
......@@ -65,7 +66,8 @@ __all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler'
'settings_handler',
'grading_handler',
'advanced_settings_handler',
'textbooks_list_handler', 'textbooks_detail_handler']
'textbooks_list_handler', 'textbooks_detail_handler',
'group_configurations_list_handler']
class AccessListFallback(Exception):
......@@ -267,7 +269,6 @@ def course_index(request, course_key):
lms_link = get_lms_link_for_item(course_module.location)
sections = course_module.get_children()
return render_to_response('overview.html', {
'context_course': course_module,
'lms_link': lms_link,
......@@ -604,7 +605,7 @@ def _config_course_advanced_components(request, course_module):
# Indicate that tabs should not be filtered out of
# the metadata
filter_tabs = False # Set this flag to avoid the tab removal code below.
found_ac_type = True #break
found_ac_type = True # break
# If we did not find a module type in the advanced settings,
# we may need to remove the tab from the course.
......@@ -854,6 +855,28 @@ def textbooks_detail_handler(request, course_key_string, textbook_id):
return JsonResponse()
@require_http_methods(("GET"))
@login_required
@ensure_csrf_cookie
def group_configurations_list_handler(request, course_key_string):
"""
A RESTful handler for Group Configurations
GET
html: return Group Configurations list page (Backbone application)
"""
course_key = CourseKey.from_string(course_key_string)
course = _get_course_module(course_key, request.user)
group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key)
splite_test_enabled = SPLIT_TEST_COMPONENT_TYPE in course.advanced_modules
return render_to_response('group_configurations.html', {
'context_course': course,
'group_configuration_url': group_configuration_url,
'configurations': [u.to_json() for u in course.user_partitions] if splite_test_enabled else None,
})
def _get_course_creator_status(user):
"""
Helper method for returning the course creator status for a particular user,
......
......@@ -12,9 +12,12 @@ from django.http import Http404
from django.test import TestCase
from django.test.client import RequestFactory
from django.core.urlresolvers import reverse
from contentstore.utils import reverse_usage_url
from contentstore.utils import reverse_usage_url, reverse_course_url
from contentstore.views.component import component_handler, get_component_templates
from contentstore.views.component import (
component_handler, get_component_templates,
SPLIT_TEST_COMPONENT_TYPE
)
from contentstore.tests.utils import CourseTestCase
from student.tests.factories import UserFactory
......@@ -824,7 +827,7 @@ class TestEditSplitModule(ItemTest):
def test_create_groups(self):
"""
Test that verticals are created for the experiment groups when
Test that verticals are created for the configuration groups when
a spit test module is edited.
"""
split_test = self.get_item_from_modulestore(self.split_test_usage_key, verify_is_draft=True)
......@@ -851,15 +854,16 @@ class TestEditSplitModule(ItemTest):
def test_change_user_partition_id(self):
"""
Test what happens when the user_partition_id is changed to a different experiment.
Test what happens when the user_partition_id is changed to a different groups
group configuration.
"""
# Set to first experiment.
# Set to first group configuration.
split_test = self._update_partition_id(0)
self.assertEqual(2, len(split_test.children))
initial_vertical_0_location = split_test.children[0]
initial_vertical_1_location = split_test.children[1]
# Set to second experiment
# Set to second group configuration
split_test = self._update_partition_id(1)
# We don't remove existing children.
self.assertEqual(5, len(split_test.children))
......@@ -881,12 +885,12 @@ class TestEditSplitModule(ItemTest):
"""
Test that nothing happens when the user_partition_id is set to the same value twice.
"""
# Set to first experiment.
# Set to first group configuration.
split_test = self._update_partition_id(0)
self.assertEqual(2, len(split_test.children))
initial_group_id_to_child = split_test.group_id_to_child
# Set again to first experiment.
# Set again to first group configuration.
split_test = self._update_partition_id(0)
self.assertEqual(2, len(split_test.children))
self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child)
......@@ -897,12 +901,12 @@ class TestEditSplitModule(ItemTest):
The user_partition_id will be updated, but children and group_id_to_child map will not change.
"""
# Set to first experiment.
# Set to first group configuration.
split_test = self._update_partition_id(0)
self.assertEqual(2, len(split_test.children))
initial_group_id_to_child = split_test.group_id_to_child
# Set to an experiment that doesn't exist.
# Set to an group configuration that doesn't exist.
split_test = self._update_partition_id(-50)
self.assertEqual(2, len(split_test.children))
self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child)
......@@ -913,7 +917,7 @@ class TestEditSplitModule(ItemTest):
Also test that deleting a child not in the group_id_to_child_map behaves properly.
"""
# Set to first experiment.
# Set to first group configuration.
self._update_partition_id(0)
split_test = self._assert_children(2)
vertical_1_usage_key = split_test.children[1]
......@@ -981,6 +985,34 @@ class TestEditSplitModule(ItemTest):
split_test = self._assert_children(3)
self.assertEqual(group_id_to_child, split_test.group_id_to_child)
def test_view_index_ok(self):
"""
Basic check that the groups configuration page responds correctly.
"""
if SPLIT_TEST_COMPONENT_TYPE not in self.course.advanced_modules:
self.course.advanced_modules.append(SPLIT_TEST_COMPONENT_TYPE)
self.store.update_item(self.course, self.user.id)
url = reverse_course_url('group_configurations_list_handler', self.course.id)
resp = self.client.get(url)
self.assertContains(resp, self.course.display_name)
self.assertContains(resp, 'First Partition')
self.assertContains(resp, 'alpha')
self.assertContains(resp, 'Second Partition')
self.assertContains(resp, 'Group 1')
def test_view_index_disabled(self):
"""
Check that group configuration page is not displayed when turned off.
"""
if SPLIT_TEST_COMPONENT_TYPE in self.course.advanced_modules:
self.course.advanced_modules.remove(SPLIT_TEST_COMPONENT_TYPE)
self.store.update_item(self.course, self.user.id)
url = reverse_course_url('group_configurations_list_handler', self.course.id)
resp = self.client.get(url)
self.assertContains(resp, "module is disabled")
@ddt.ddt
class TestComponentHandler(TestCase):
......
......@@ -214,6 +214,7 @@ define([
"js/spec/models/component_template_spec",
"js/spec/models/explicit_url_spec",
"js/spec/models/group_configuration_spec",
"js/spec/utils/drag_and_drop_spec",
"js/spec/utils/handle_iframe_binding_spec",
......@@ -229,10 +230,13 @@ define([
"js/spec/views/xblock_editor_spec",
"js/spec/views/pages/container_spec",
"js/spec/views/pages/group_configurations_spec",
"js/spec/views/modals/base_modal_spec",
"js/spec/views/modals/edit_xblock_spec",
"js/spec/views/group_configuration_spec",
"js/spec/xblock/cms.runtime.v1_spec",
# these tests are run separately in the cms-squire suite, due to process
......
define([
'backbone', 'js/models/group'
],
function (Backbone, GroupModel) {
'use strict';
var GroupCollection = Backbone.Collection.extend({
model: GroupModel,
/**
* Indicates if the collection is empty when all the models are empty
* or the collection does not include any models.
**/
isEmpty: function() {
return this.length === 0 || this.every(function(m) {
return m.isEmpty();
});
}
});
return GroupCollection;
});
define([
'backbone', 'js/models/group_configuration'
],
function(Backbone, GroupConfigurationModel) {
'use strict';
var GroupConfigurationCollection = Backbone.Collection.extend({
model: GroupConfigurationModel
});
return GroupConfigurationCollection;
});
define([
'backbone', 'gettext', 'backbone.associations'
], function(Backbone, gettext) {
'use strict';
var Group = Backbone.AssociatedModel.extend({
defaults: function() {
return { name: '' };
},
isEmpty: function() {
return !this.get('name');
},
toJSON: function() {
return { name: this.get('name') };
},
validate: function(attrs) {
if (!attrs.name) {
return {
message: gettext('Group name is required'),
attributes: { name: true }
};
}
}
});
return Group;
});
define([
'backbone', 'underscore', 'gettext', 'js/models/group',
'js/collections/group', 'backbone.associations', 'coffee/src/main'
],
function(Backbone, _, gettext, GroupModel, GroupCollection) {
'use strict';
var GroupConfiguration = Backbone.AssociatedModel.extend({
defaults: function() {
return {
id: null,
name: '',
description: '',
groups: new GroupCollection([{}, {}]),
showGroups: false
};
},
relations: [{
type: Backbone.Many,
key: 'groups',
relatedModel: GroupModel,
collectionType: GroupCollection
}],
initialize: function() {
this.setOriginalAttributes();
return this;
},
setOriginalAttributes: function() {
this._originalAttributes = this.toJSON();
},
reset: function() {
this.set(this._originalAttributes);
},
isDirty: function() {
return !_.isEqual(
this._originalAttributes, this.toJSON()
);
},
isEmpty: function() {
return !this.get('name') && this.get('groups').isEmpty();
},
toJSON: function() {
return {
id: this.get('id'),
name: this.get('name'),
description: this.get('description'),
groups: this.get('groups').toJSON()
};
},
validate: function(attrs) {
if (!attrs.name) {
return {
message: gettext('Group Configuration name is required'),
attributes: {name: true}
};
}
if (attrs.groups.length === 0) {
return {
message: gettext('Please add at least one group'),
attributes: {groups: true}
};
} else {
// validate all groups
var invalidGroups = [];
attrs.groups.each(function(group) {
if(!group.isValid()) {
invalidGroups.push(group);
}
});
if (!_.isEmpty(invalidGroups)) {
return {
message: gettext('All groups must have a name'),
attributes: {groups: invalidGroups}
};
}
}
}
});
return GroupConfiguration;
});
define([
'backbone', 'js/models/group_configuration',
'js/collections/group_configuration', 'js/models/group',
'js/collections/group', 'coffee/src/main'
], function(
Backbone, GroupConfiguration, GroupConfigurationSet, Group, GroupSet, main
) {
'use strict';
beforeEach(function() {
this.addMatchers({
toBeInstanceOf: function(expected) {
return this.actual instanceof expected;
}
});
});
describe('GroupConfiguration model', function() {
beforeEach(function() {
main();
this.model = new GroupConfiguration();
});
describe('Basic', function() {
it('should have an empty name by default', function() {
expect(this.model.get('name')).toEqual('');
});
it('should have an empty description by default', function() {
expect(this.model.get('description')).toEqual('');
});
it('should not show groups by default', function() {
expect(this.model.get('showGroups')).toBeFalsy();
});
it('should have a GroupSet with two groups by default', function() {
var groups = this.model.get('groups');
expect(groups).toBeInstanceOf(GroupSet);
expect(groups.length).toEqual(2);
expect(groups.at(0).isEmpty()).toBeTruthy();
expect(groups.at(1).isEmpty()).toBeTruthy();
});
it('should be empty by default', function() {
expect(this.model.isEmpty()).toBeTruthy();
});
it('should be able to reset itself', function() {
this.model.set('name', 'foobar');
this.model.reset();
expect(this.model.get('name')).toEqual('');
});
it('should not be dirty by default', function() {
expect(this.model.isDirty()).toBeFalsy();
});
it('should be dirty after it\'s been changed', function() {
this.model.set('name', 'foobar');
expect(this.model.isDirty()).toBeTruthy();
});
it('should not be dirty after calling setOriginalAttributes', function() {
this.model.set('name', 'foobar');
this.model.setOriginalAttributes();
expect(this.model.isDirty()).toBeFalsy();
});
});
describe('Input/Output', function() {
var deepAttributes = function(obj) {
if (obj instanceof Backbone.Model) {
return deepAttributes(obj.attributes);
} else if (obj instanceof Backbone.Collection) {
return obj.map(deepAttributes);
} else if (_.isArray(obj)) {
return _.map(obj, deepAttributes);
} else if (_.isObject(obj)) {
var attributes = {};
for (var prop in obj) {
if (obj.hasOwnProperty(prop)) {
attributes[prop] = deepAttributes(obj[prop]);
}
}
return attributes;
} else {
return obj;
}
};
it('should match server model to client model', function() {
var serverModelSpec = {
'id': 10,
'name': 'My GroupConfiguration',
'description': 'Some description',
'groups': [
{
'name': 'Group 1'
}, {
'name': 'Group 2'
}
]
},
clientModelSpec = {
'id': 10,
'name': 'My GroupConfiguration',
'description': 'Some description',
'showGroups': false,
'groups': [
{
'name': 'Group 1'
}, {
'name': 'Group 2'
}
]
},
model = new GroupConfiguration(serverModelSpec);
expect(deepAttributes(model)).toEqual(clientModelSpec);
expect(model.toJSON()).toEqual(serverModelSpec);
});
});
describe('Validation', function() {
it('requires a name', function() {
var model = new GroupConfiguration({ name: '' });
expect(model.isValid()).toBeFalsy();
});
it('requires at least one group', function() {
var model = new GroupConfiguration({ name: 'foo' });
model.get('groups').reset();
expect(model.isValid()).toBeFalsy();
});
it('requires a valid group', function() {
var group = new Group(),
model = new GroupConfiguration({ name: 'foo' });
group.isValid = function() { return false; };
model.get('groups').reset([group]);
expect(model.isValid()).toBeFalsy();
});
it('requires all groups to be valid', function() {
var group1 = new Group(),
group2 = new Group(),
model = new GroupConfiguration({ name: 'foo' });
group1.isValid = function() { return true; };
group2.isValid = function() { return false; };
model.get('groups').reset([group1, group2]);
expect(model.isValid()).toBeFalsy();
});
it('can pass validation', function() {
var group = new Group(),
model = new GroupConfiguration({ name: 'foo' });
group.isValid = function() { return true; };
model.get('groups').reset([group]);
expect(model.isValid()).toBeTruthy();
});
});
});
describe('Group model', function() {
beforeEach(function() {
this.model = new Group();
});
describe('Basic', function() {
it('should have a name by default', function() {
expect(this.model.get('name')).toEqual('');
});
it('should be empty by default', function() {
expect(this.model.isEmpty()).toBeTruthy();
});
});
describe('Validation', function() {
it('requires a name', function() {
var model = new Group({ name: '' });
expect(model.isValid()).toBeFalsy();
});
it('can pass validation', function() {
var model = new Group({ name: 'a' });
expect(model.isValid()).toBeTruthy();
});
});
});
describe('Group collection', function() {
beforeEach(function() {
this.collection = new GroupSet();
});
it('is empty by default', function() {
expect(this.collection.isEmpty()).toBeTruthy();
});
it('is empty if all groups are empty', function() {
this.collection.add([{}, {}, {}]);
expect(this.collection.isEmpty()).toBeTruthy();
});
it('is not empty if a group is not empty', function() {
this.collection.add([{}, { name: 'full' }, {} ]);
expect(this.collection.isEmpty()).toBeFalsy();
});
});
});
define([
'js/models/group_configuration', 'js/models/course',
'js/collections/group_configuration', 'js/views/group_configuration_details',
'js/views/group_configurations_list', 'jasmine-stealth'
], function(
GroupConfigurationModel, Course, GroupConfigurationSet,
GroupConfigurationDetails, GroupConfigurationsList
) {
'use strict';
beforeEach(function() {
window.course = new Course({
id: '5',
name: 'Course Name',
url_name: 'course_name',
org: 'course_org',
num: 'course_num',
revision: 'course_rev'
});
this.addMatchers({
toContainText: function(text) {
var trimmedText = $.trim(this.actual.text());
if (text && $.isFunction(text.test)) {
return text.test(trimmedText);
} else {
return trimmedText.indexOf(text) !== -1;
}
}
});
});
afterEach(function() {
delete window.course;
});
describe('GroupConfigurationDetails', function() {
var tpl = readFixtures('group-configuration-details.underscore');
beforeEach(function() {
setFixtures($('<script>', {
id: 'group-configuration-details-tpl',
type: 'text/template'
}).text(tpl));
this.model = new GroupConfigurationModel({
name: 'Configuration',
description: 'Configuration Description',
id: 0
});
spyOn(this.model, 'destroy').andCallThrough();
this.collection = new GroupConfigurationSet([ this.model ]);
this.view = new GroupConfigurationDetails({
model: this.model
});
});
describe('Basic', function() {
it('should render properly', function() {
this.view.render();
expect(this.view.$el).toContainText('Configuration');
expect(this.view.$el).toContainText('ID: 0');
});
it('should show groups appropriately', function() {
this.model.get('groups').add([{}, {}, {}]);
this.model.set('showGroups', false);
this.view.render().$('.show-groups').click();
expect(this.model.get('showGroups')).toBeTruthy();
expect(this.view.$el.find('.group').length).toBe(5);
expect(this.view.$el.find('.group-configuration-groups-count'))
.not.toExist();
expect(this.view.$el.find('.group-configuration-description'))
.toContainText('Configuration Description');
expect(this.view.$el.find('.group-allocation'))
.toContainText('20%');
});
it('should hide groups appropriately', function() {
this.model.get('groups').add([{}, {}, {}]);
this.model.set('showGroups', true);
this.view.render().$('.hide-groups').click();
expect(this.model.get('showGroups')).toBeFalsy();
expect(this.view.$el.find('.group').length).toBe(0);
expect(this.view.$el.find('.group-configuration-groups-count'))
.toContainText('Contains 5 groups');
expect(this.view.$el.find('.group-configuration-description'))
.not.toExist();
expect(this.view.$el.find('.group-allocation'))
.not.toExist();
});
});
});
describe('GroupConfigurationsList', function() {
var noGroupConfigurationsTpl = readFixtures(
'no-group-configurations.underscore'
);
beforeEach(function() {
var showEl = $('<li>');
setFixtures($('<script>', {
id: 'no-group-configurations-tpl',
type: 'text/template'
}).text(noGroupConfigurationsTpl));
this.showSpies = spyOnConstructor(
window, 'GroupConfigurationDetails', [ 'render' ]
);
this.showSpies.render.andReturn(this.showSpies);
this.showSpies.$el = showEl;
this.showSpies.el = showEl.get(0);
this.collection = new GroupConfigurationSet();
this.view = new GroupConfigurationsList({
collection: this.collection
});
this.view.render();
});
var message = 'should render the empty template if there are no group ' +
'configurations';
it(message, function() {
expect(this.view.$el).toContainText(
'You haven\'t created any group configurations yet.'
);
expect(this.view.$el).not.toContain('.new-button');
expect(this.showSpies.constructor).not.toHaveBeenCalled();
});
it('should render GroupConfigurationDetails views by default', function() {
this.collection.add([{}, {}, {}]);
this.view.render();
expect(this.view.$el).not.toContainText(
'You haven\'t created any group configurations yet.'
);
expect(this.view.$el.find('.group-configuration').length).toBe(3);
});
});
});
define([
'jquery', 'underscore', 'js/views/pages/group_configurations',
'js/collections/group_configuration'
], function ($, _, GroupConfigurationsPage, GroupConfigurationCollection) {
'use strict';
describe('GroupConfigurationsPage', function() {
var mockGroupConfigurationsPage = readFixtures(
'mock/mock-group-configuration-page.underscore'
),
noGroupConfigurationsTpl = readFixtures(
'no-group-configurations.underscore'
), view;
var initializePage = function (disableSpy) {
view = new GroupConfigurationsPage({
el: $('.content-primary'),
collection: new GroupConfigurationCollection({
name: 'Configuration 1'
})
});
if (!disableSpy) {
spyOn(view, 'addGlobalActions');
}
};
beforeEach(function () {
setFixtures($('<script>', {
id: 'no-group-configurations-tpl',
type: 'text/template'
}).text(noGroupConfigurationsTpl));
appendSetFixtures(mockGroupConfigurationsPage);
});
describe('Initial display', function() {
it('can render itself', function() {
initializePage();
expect(view.$('.ui-loading')).toBeVisible();
view.render();
expect(view.$('.no-group-configurations-content')).toBeTruthy();
expect(view.$('.ui-loading')).toBeHidden();
});
});
describe('on page close/change', function() {
it('I see notification message if the model is changed',
function() {
var message;
initializePage(true);
view.render();
message = view.onBeforeUnload();
expect(message).toBeUndefined();
});
it('I do not see notification message if the model is not changed',
function() {
var expectedMessage = [
'You have unsaved changes. Do you really want to ',
'leave this page?'
].join(''), message;
initializePage();
view.render();
view.collection.at(0).set('name', 'Configuration 2');
message = view.onBeforeUnload();
expect(message).toBe(expectedMessage);
});
});
});
});
define([
'js/views/baseview', 'underscore', 'gettext'
],
function(BaseView, _, gettext) {
'use strict';
var GroupConfigurationDetails = BaseView.extend({
tagName: 'section',
className: 'group-configuration',
events: {
'click .show-groups': 'showGroups',
'click .hide-groups': 'hideGroups'
},
initialize: function() {
this.template = _.template(
$('#group-configuration-details-tpl').text()
);
this.listenTo(this.model, 'change', this.render);
},
render: function() {
var attrs = $.extend({}, this.model.attributes, {
groupsCountMessage: this.getGroupsCountTitle(),
index: this.model.collection.indexOf(this.model)
});
this.$el.html(this.template(attrs));
return this;
},
showGroups: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set('showGroups', true);
},
hideGroups: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set('showGroups', false);
},
getGroupsCountTitle: function () {
var count = this.model.get('groups').length,
message = ngettext(
// Translators: 'count' is number of groups that the group configuration contains.
'Contains %(count)s group', 'Contains %(count)s groups',
count
);
return interpolate(message, { count: count }, true);
}
});
return GroupConfigurationDetails;
});
define(['js/views/baseview', 'jquery', 'js/views/group_configuration_details'],
function(BaseView, $, GroupConfigurationDetailsView) {
'use strict';
var GroupConfigurationsList = BaseView.extend({
tagName: 'div',
className: 'group-configurations-list',
events: { },
initialize: function() {
this.emptyTemplate = this.loadTemplate('no-group-configurations');
this.listenTo(this.collection, 'all', this.render);
},
render: function() {
var configurations = this.collection;
if(configurations.length === 0) {
this.$el.html(this.emptyTemplate());
} else {
var frag = document.createDocumentFragment();
configurations.each(function(configuration) {
var view = new GroupConfigurationDetailsView({
model: configuration
});
frag.appendChild(view.render().el);
});
this.$el.html([frag]);
}
return this;
}
});
return GroupConfigurationsList;
});
define([
'jquery', 'underscore', 'gettext', 'js/views/baseview',
'js/views/group_configurations_list'
],
function ($, _, gettext, BaseView, ConfigurationsListView) {
'use strict';
var GroupConfigurationsPage = BaseView.extend({
initialize: function() {
BaseView.prototype.initialize.call(this);
this.listView = new ConfigurationsListView({
collection: this.collection
});
},
render: function() {
this.hideLoadingIndicator();
this.$el.append(this.listView.render().el);
this.addGlobalActions();
},
addGlobalActions: function () {
$(window).on('beforeunload', this.onBeforeUnload.bind(this));
},
onBeforeUnload: function () {
var dirty = this.collection.find(function(configuration) {
return configuration.isDirty();
});
if(dirty) {
return gettext(
'You have unsaved changes. Do you really want to ' +
'leave this page?'
);
}
}
});
return GroupConfigurationsPage;
}); // end define();
......@@ -55,6 +55,12 @@ nav {
&:hover {
}
&.nav-course-settings {
.wrapper-nav-sub {
width: ($baseline*9);
}
}
}
.wrapper-nav-sub {
......
......@@ -47,6 +47,7 @@
@import 'views/checklists';
@import 'views/textbooks';
@import 'views/export-git';
@import 'views/group-configuration';
// base - contexts
@import 'contexts/ie'; // ie-specific rules (mostly for known/older bugs)
......
// studio - views - group-configurations
// ====================
.view-group-configurations {
.content-primary, .content-supplementary {
@include box-sizing(border-box);
float: left;
}
.content-primary {
width: flex-grid(9, 12);
margin-right: flex-gutter();
.notice-moduledisabled {
@extend %ui-well;
@extend %t-copy-base;
background-color: $white;
padding: ($baseline*1.5) $baseline;
text-align: center;
}
.no-group-configurations-content {
@extend %ui-well;
padding: ($baseline*2);
background-color: $gray-l4;
text-align: center;
color: $gray;
}
.group-configuration {
@extend %ui-window;
position: relative;
.view-group-configuration {
padding: $baseline ($baseline*1.5);
.group-configuration-header {
margin-bottom: 0;
border-bottom: 0;
}
.group-configuration-title {
@extend %t-title;
@include font-size(22);
@include line-height(22);
overflow: hidden;
text-overflow: ellipsis;
margin-right: ($baseline*14);
font-weight: bold;
.group-toggle {
display: inline-block;
padding-left: $baseline;
color: $black;
&:hover, &:focus {
color: $blue;
}
}
}
.group-configuration-info {
color: $gray-l1;
margin-left: $baseline;
&.group-configuration-info-inline {
display: table;
width: 70%;
margin: ($baseline/4) 0 ($baseline/2) $baseline;
li {
@include box-sizing(border-box);
display: table-cell;
margin-right: 1%;
}
}
&.group-configuration-info-block {
li {
padding: ($baseline/4) 0;
}
}
.group-configuration-label {
text-transform: uppercase;
}
.group-configuration-description {
overflow: hidden;
text-overflow: ellipsis;
}
}
.ui-toggle-expansion {
@include transition(rotate .15s ease-in-out .25s);
@include font-size(21);
display: inline-block;
width: ($baseline*0.75);
vertical-align: baseline;
margin-left: -$baseline;
}
&.is-selectable {
cursor: pointer;
&:hover {
color: $blue;
.ui-toggle-expansion {
color: $blue;
}
}
}
.groups {
margin-left: $baseline;
margin-bottom: ($baseline*0.75);
.group {
@extend %t-copy-sub2;
@include font-size(18);
@include line-height(16);
padding: ($baseline/7) 0 ($baseline/4);
border-top: 1px solid $gray-l4;
white-space: nowrap;
&:first-child {
border-top: none;
}
.group-name {
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
vertical-align: middle;
width: 75%;
margin-right: 5%;
}
.group-allocation {
display: inline-block;
vertical-align: middle;
width: 20%;
color: $gray-l1;
text-align: right;
}
}
}
}
&:hover .actions {
opacity: 1.0;
}
}
}
.content-supplementary {
width: flex-grid(3, 12);
}
}
<%inherit file="base.html" />
<%def name="online_help_token()"><% return "group_configurations" %></%def>
<%namespace name='static' file='static_content.html'/>
<%! import json %>
<%!
from contentstore import utils
from django.utils.translation import ugettext as _
%>
<%block name="title">${_("Group Configurations")}</%block>
<%block name="bodyclass">is-signedin course view-group-configurations</%block>
<%block name="header_extras">
% for template_name in ["group-configuration-details", "no-group-configurations", "basic-modal", "modal-button"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="jsextra">
<script type="text/javascript">
require(["!domReady", "js/collections/group_configuration", "js/views/pages/group_configurations"],
function(doc, GroupConfigurationCollection, GroupConfigurationsPage, xmoduleLoader) {
% if configurations is not None:
var view = new GroupConfigurationsPage({
el: $('.content-primary'),
collection: new GroupConfigurationCollection(${json.dumps(configurations)}, { url: "${group_configuration_url}" })
});
view.render();
% endif
});
</script>
</%block>
<%block name="content">
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">${_("Settings")}</small>
<span class="sr">&gt; </span>${_("Group Configurations")}
</h1>
</header>
</div>
<div class="wrapper-content wrapper">
<section class="content">
<article class="content-primary" role="main">
% if configurations is None:
<div class="notice notice-incontext notice-moduledisabled">
<p class="copy">
${_("This module is disabled at the moment.")}
</p>
</div>
% else:
<div class="ui-loading">
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">${_("Loading...")}</span></p>
</div>
% endif
</article>
<aside class="content-supplementary" role="complimentary">
<div class="bit">
% if context_course:
<%
details_url = utils.reverse_course_url('settings_handler', context_course.id)
grading_url = utils.reverse_course_url('grading_handler', context_course.id)
course_team_url = utils.reverse_course_url('course_team_handler', context_course.id)
advanced_settings_url = utils.reverse_course_url('advanced_settings_handler', context_course.id)
%>
<h3 class="title-3">${_("Other Course Settings")}</h3>
<nav class="nav-related">
<ul>
<li class="nav-item"><a href="${details_url}">${_("Details &amp; Schedule")}</a></li>
<li class="nav-item"><a href="${grading_url}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${advanced_settings_url}">${_("Advanced Settings")}</a></li>
</ul>
</nav>
% endif
</div>
</aside>
</section>
</div>
</%block>
<div class="view-group-configuration view-group-configuration-<%= index %>">
<div class="wrapper-group-configuration">
<header class="group-configuration-header">
<h3 class="group-configuration-title">
<a href="#" class="group-toggle <% if(showGroups){ print('hide'); } else { print('show'); } %>-groups">
<i class="ui-toggle-expansion icon-caret-<% if(showGroups){ print('down'); } else { print('right'); } %>"></i>
<%= name %>
</a>
</h3>
</header>
<ol class="group-configuration-info group-configuration-info-<% if(showGroups){ print('block'); } else { print('inline'); } %>">
<% if (_.isNumber(id)) { %>
<li class="group-configuration-id"
><span class="group-configuration-label"><%= gettext('ID') %>: </span
><span class="group-configuration-value"><%= id %></span
></li>
<% } %>
<% if (showGroups) { %>
<li class="group-configuration-description">
<%= description %>
</li>
<% } else { %>
<li class="group-configuration-groups-count">
<%= groupsCountMessage %>
</li>
<% } %>
</ol>
<% if(showGroups) { %>
<% allocation = Math.floor(100 / groups.length) %>
<ol class="groups groups-<%= index %>">
<% groups.each(function(group, groupIndex) { %>
<li class="group group-<%= groupIndex %>"
><span class="group-name"><%= group.get('name') %></span
><span class="group-allocation"><%= allocation %>%</span
></li>
<% }) %>
</ol>
<% } %>
</div>
</div>
<div id="content">
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">${_("Settings")}</small>
<span class="sr">&gt; </span>${_("Group Configurations")}
</h1>
</header>
</div>
<div class="wrapper-content wrapper">
<section class="content">
<article class="content-primary" role="main">
<div class="ui-loading">
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">${_("Loading...")}</span></p>
</div>
</article>
<aside class="content-supplementary" role="complimentary"></aside>
</section>
</div>
</div>
<div class="no-group-configurations-content">
<p><%= gettext("You haven't created any group configurations yet.") %></p>
</div>
......@@ -309,6 +309,7 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
course_team_url = utils.reverse_course_url('course_team_handler', context_course.id)
grading_config_url = utils.reverse_course_url('grading_handler', context_course.id)
advanced_config_url = utils.reverse_course_url('advanced_settings_handler', context_course.id)
group_configurations_config_url = utils.reverse_course_url('group_configurations_list_handler', context_course.id)
%>
<h3 class="title-3">${_("Other Course Settings")}</h3>
<nav class="nav-related">
......@@ -316,6 +317,9 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
<li class="nav-item"><a href="${grading_config_url}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${advanced_config_url}">${_("Advanced Settings")}</a></li>
% if "split_test" in context_course.advanced_modules:
<li class="nav-item"><a href="${group_configurations_config_url}">${_("Group Configurations")}</a></li>
% endif
</ul>
</nav>
% endif
......
......@@ -118,6 +118,7 @@ require(["domReady!", "jquery", "gettext", "js/models/settings/advanced", "js/vi
details_url = utils.reverse_course_url('settings_handler', context_course.id)
grading_url = utils.reverse_course_url('grading_handler', context_course.id)
course_team_url = utils.reverse_course_url('course_team_handler', context_course.id)
group_configurations_config_url = utils.reverse_course_url('group_configurations_list_handler', context_course.id)
%>
<h3 class="title-3">${_("Other Course Settings")}</h3>
<nav class="nav-related">
......@@ -125,6 +126,9 @@ require(["domReady!", "jquery", "gettext", "js/models/settings/advanced", "js/vi
<li class="nav-item"><a href="${details_url}">${_("Details &amp; Schedule")}</a></li>
<li class="nav-item"><a href="${grading_url}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
% if "split_test" in context_course.advanced_modules:
<li class="nav-item"><a href="${group_configurations_config_url}">${_("Group Configurations")}</a></li>
% endif
</ul>
</nav>
% endif
......
......@@ -141,6 +141,7 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings
detailed_settings_url = utils.reverse_course_url('settings_handler', context_course.id)
course_team_url = utils.reverse_course_url('course_team_handler', context_course.id)
advanced_settings_url = utils.reverse_course_url('advanced_settings_handler', context_course.id)
group_configurations_config_url = utils.reverse_course_url('group_configurations_list_handler', context_course.id)
%>
<h3 class="title-3">${_("Other Course Settings")}</h3>
<nav class="nav-related">
......@@ -148,6 +149,9 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings
<li class="nav-item"><a href="${detailed_settings_url}">${_("Details &amp; Schedule")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${advanced_settings_url}">${_("Advanced Settings")}</a></li>
% if "split_test" in context_course.advanced_modules:
<li class="nav-item"><a href="${group_configurations_config_url}">${_("Group Configurations")}</a></li>
% endif
</ul>
</nav>
% endif
......
......@@ -27,6 +27,7 @@
settings_url = reverse('contentstore.views.settings_handler', kwargs={'course_key_string': unicode(course_key)})
grading_url = reverse('contentstore.views.grading_handler', kwargs={'course_key_string': unicode(course_key)})
advanced_settings_url = reverse('contentstore.views.advanced_settings_handler', kwargs={'course_key_string': unicode(course_key)})
group_configurations_config_url = reverse('contentstore.views.group_configurations_list_handler', kwargs={'course_key_string': unicode(course_key)})
tabs_url = reverse('contentstore.views.tabs_handler', kwargs={'course_key_string': unicode(course_key)})
%>
<h2 class="info-course">
......@@ -84,6 +85,11 @@
<li class="nav-item nav-course-settings-advanced">
<a href="${advanced_settings_url}">${_("Advanced Settings")}</a>
</li>
% if "split_test" in context_course.advanced_modules:
<li class="nav-item nav-course-settings-group-configurations">
<a href="${group_configurations_config_url}">${_("Group Configurations")}</a>
</li>
% endif
</ul>
</div>
</div>
......
......@@ -90,6 +90,7 @@ urlpatterns += patterns(
url(r'^settings/advanced/(?P<course_key_string>[^/]+)$', 'advanced_settings_handler'),
url(r'^textbooks/(?P<course_key_string>[^/]+)$', 'textbooks_list_handler'),
url(r'^textbooks/(?P<course_key_string>[^/]+)/(?P<textbook_id>\d[^/]*)$', 'textbooks_detail_handler'),
url(r'^group_configurations/(?P<course_key_string>[^/]+)$', 'group_configurations_list_handler'),
)
js_info_dict = {
......
......@@ -3,6 +3,10 @@ Course Advanced Settings page
"""
from .course_page import CoursePage
from .utils import press_the_notification_button, type_in_codemirror, get_codemirror_value
KEY_CSS = '.key h3.title'
class AdvancedSettingsPage(CoursePage):
......@@ -14,3 +18,27 @@ class AdvancedSettingsPage(CoursePage):
def is_browser_on_page(self):
return self.q(css='body.advanced').present
def _get_index_of(self, expected_key):
for i, element in enumerate(self.q(css=KEY_CSS)):
# Sometimes get stale reference if I hold on to the array of elements
key = self.q(css=KEY_CSS).nth(i).text[0]
if key == expected_key:
return i
return -1
def save(self):
press_the_notification_button(self, "Save")
def cancel(self):
press_the_notification_button(self, "Cancel")
def set(self, key, new_value):
index = self._get_index_of(key)
type_in_codemirror(self, index, new_value)
self.save()
def get(self, key):
index = self._get_index_of(key)
return get_codemirror_value(self, index)
"""
Course Group Configurations page.
"""
from .course_page import CoursePage
class GroupConfigurationsPage(CoursePage):
"""
Course Group Configurations page.
"""
url_path = "group_configurations"
def is_browser_on_page(self):
return self.q(css='body.view-group-configurations').present
def group_configurations(self):
"""
Returns list of the group configurations for the course.
"""
css = '.wrapper-group-configuration'
return [GroupConfiguration(self, index) for index in xrange(len(self.q(css=css)))]
class GroupConfiguration(object):
"""
Group Configuration wrapper.
"""
def __init__(self, page, index):
self.page = page
self.SELECTOR = '.view-group-configuration-{}'.format(index)
self.index = index
def get_selector(self, css=''):
return ' '.join([self.SELECTOR, css])
def find_css(self, selector):
"""
Find elements as defined by css locator.
"""
return self.page.q(css=self.get_selector(css=selector))
def toggle(self):
"""
Expand/collapse group configuration.
"""
css = 'a.group-toggle'
self.find_css(css).first.click()
@property
def id(self):
"""
Returns group configuration id.
"""
css = '.group-configuration-id .group-configuration-value'
return self.find_css(css).first.text[0]
@property
def name(self):
"""
Returns group configuration name.
"""
css = '.group-configuration-title'
return self.find_css(css).first.text[0]
@property
def description(self):
"""
Returns group configuration description.
"""
css = '.group-configuration-description'
return self.find_css(css).first.text[0]
@property
def groups(self):
"""
Returns list of groups.
"""
css = '.group'
def group_selector(config_index, group_index):
return self.get_selector('.groups-{} .group-{} '.format(config_index, group_index))
return [Group(self.page, group_selector(self.index, index)) for index, element in enumerate(self.find_css(css))]
def __repr__(self):
return "<{}:{}>".format(self.__class__.__name__, self.name)
class Group(object):
"""
Group wrapper.
"""
def __init__(self, page, prefix_selector):
self.page = page
self.prefix = prefix_selector
def find_css(self, selector):
"""
Find elements as defined by css locator.
"""
return self.page.q(css=self.prefix + selector)
@property
def name(self):
"""
Returns group name.
"""
css = '.group-name'
return self.find_css(css).first.text[0]
@property
def allocation(self):
"""
Returns allocation for the group.
"""
css = '.group-allocation'
return self.find_css(css).first.text[0]
def __repr__(self):
return "<{}:{}>".format(self.__class__.__name__, self.name)
......@@ -37,6 +37,18 @@ def wait_for_notification(page):
Promise(_is_saving_done, 'Notification should have been hidden.', timeout=60).fulfill()
def press_the_notification_button(page, name):
# Because the notification uses a CSS transition,
# Selenium will always report it as being visible.
# This makes it very difficult to successfully click
# the "Save" button at the UI level.
# Instead, we use JavaScript to reliably click
# the button.
btn_css = 'div#page-notification a.action-%s' % name.lower()
page.browser.execute_script("$('{}').focus().click()".format(btn_css))
page.wait_for_ajax()
def add_discussion(page, menu_index):
"""
Add a new instance of the discussion category.
......@@ -66,3 +78,20 @@ def add_advanced_component(page, menu_index, name):
Promise(is_advanced_components_showing, "Advanced component menu not showing").fulfill()
click_css(page, 'a[data-category={}]'.format(name))
def type_in_codemirror(page, index, text, find_prefix="$"):
script = """
var cm = {find_prefix}('div.CodeMirror:eq({index})').get(0).CodeMirror;
CodeMirror.signal(cm, "focus", cm);
cm.setValue(arguments[0]);
CodeMirror.signal(cm, "blur", cm);""".format(index=index, find_prefix=find_prefix)
page.browser.execute_script(script, str(text))
def get_codemirror_value(page, index=0, find_prefix="$"):
return page.browser.execute_script(
"""
return {find_prefix}('div.CodeMirror:eq({index})').get(0).CodeMirror.getValue();
""".format(index=index, find_prefix=find_prefix)
)
......@@ -2,16 +2,22 @@
Acceptance tests for Studio related to the split_test module.
"""
import json
from unittest import skip
from ..fixtures.course import CourseFixture, XBlockFixtureDesc
from ..pages.studio.component_editor import ComponentEditorView
from ..pages.studio.settings_advanced import AdvancedSettingsPage
from ..pages.studio.settings_group_configurations import GroupConfigurationsPage
from ..pages.studio.auto_auth import AutoAuthPage
from test_studio_container import ContainerBase
from ..pages.studio.utils import add_advanced_component
from xmodule.partitions.partitions import Group, UserPartition
from bok_choy.promise import Promise
from .helpers import UniqueCourseTest
class SplitTest(ContainerBase):
"""
......@@ -154,3 +160,147 @@ class SplitTest(ContainerBase):
container = self.create_poorly_configured_split_instance()
container.delete(0)
self.verify_groups(container, ['alpha'], [], verify_missing_groups_not_present=False)
class SettingsMenuTest(UniqueCourseTest):
"""
Tests that Setting menu is rendered correctly in Studio
"""
def setUp(self):
super(SettingsMenuTest, self).setUp()
course_fix = CourseFixture(**self.course_info)
course_fix.install()
self.auth_page = AutoAuthPage(
self.browser,
staff=False,
username=course_fix.user.get('username'),
email=course_fix.user.get('email'),
password=course_fix.user.get('password')
)
self.auth_page.visit()
self.advanced_settings = AdvancedSettingsPage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
self.advanced_settings.visit()
def test_link_exist_if_split_test_enabled(self):
"""
Ensure that the link to the "Group Configurations" page is shown in the
Settings menu.
"""
link_css = 'li.nav-course-settings-group-configurations a'
self.assertFalse(self.advanced_settings.q(css=link_css).present)
self.advanced_settings.set('Advanced Module List', '["split_test"]')
self.browser.refresh()
self.advanced_settings.wait_for_page()
self.assertIn(
"split_test",
json.loads(self.advanced_settings.get('Advanced Module List')),
)
self.assertTrue(self.advanced_settings.q(css=link_css).present)
def test_link_does_not_exist_if_split_test_disabled(self):
"""
Ensure that the link to the "Group Configurations" page does not exist
in the Settings menu.
"""
link_css = 'li.nav-course-settings-group-configurations a'
self.advanced_settings.set('Advanced Module List', '[]')
self.browser.refresh()
self.advanced_settings.wait_for_page()
self.assertFalse(self.advanced_settings.q(css=link_css).present)
class GroupConfigurationsTest(UniqueCourseTest):
"""
Tests that Group Configurations page works correctly with previously
added configurations in Studio
"""
def setUp(self):
super(GroupConfigurationsTest, self).setUp()
course_fix = CourseFixture(**self.course_info)
course_fix.add_advanced_settings({
u"advanced_modules": {"value": ["split_test"]},
})
course_fix.install()
self.course_fix = course_fix
self.user = course_fix.user
self.auth_page = AutoAuthPage(
self.browser,
staff=False,
username=course_fix.user.get('username'),
email=course_fix.user.get('email'),
password=course_fix.user.get('password')
)
self.auth_page.visit()
self.page = GroupConfigurationsPage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
def test_no_group_configurations_added(self):
"""
Ensure that message telling me to create a new group configuration is
shown when group configurations were not added.
"""
self.page.visit()
css = ".wrapper-content .no-group-configurations-content"
self.assertTrue(self.page.q(css=css).present)
self.assertIn(
"You haven't created any group configurations yet.",
self.page.q(css=css).text[0]
)
def test_group_configurations_have_correct_data(self):
"""
Ensure that the group configuration is rendered correctly in
expanded/collapsed mode.
"""
self.course_fix.add_advanced_settings({
u"user_partitions": {
"value": [
UserPartition(0, 'Name of the Group Configuration', 'Description of the group configuration.', [Group("0", 'Group 0'), Group("1", 'Group 1')]).to_json(),
UserPartition(1, 'Name of second Group Configuration', 'Second group configuration.', [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]).to_json()
],
},
})
self.course_fix._add_advanced_settings()
self.page.visit()
config = self.page.group_configurations()[0]
self.assertIn("Name of the Group Configuration", config.name)
self.assertEqual(config.id, '0')
config.toggle()
self.assertIn("Description of the group configuration.", config.description)
self.assertEqual(len(config.groups), 2)
self.assertEqual("Group 0", config.groups[0].name)
self.assertEqual("50%", config.groups[0].allocation)
config = self.page.group_configurations()[1]
self.assertIn("Name of second Group Configuration", config.name)
self.assertEqual(len(config.groups), 0) # no groups when the partition is collapsed
config.toggle()
self.assertEqual(len(config.groups), 3)
self.assertEqual("Beta", config.groups[1].name)
self.assertEqual("33%", config.groups[1].allocation)
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