BLD-1117: Add read-only list of Group Configurations.
This commit is contained in:
@@ -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
|
||||
|
||||
20
cms/static/js/collections/group.js
Normal file
20
cms/static/js/collections/group.js
Normal file
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
});
|
||||
11
cms/static/js/collections/group_configuration.js
Normal file
11
cms/static/js/collections/group_configuration.js
Normal file
@@ -0,0 +1,11 @@
|
||||
define([
|
||||
'backbone', 'js/models/group_configuration'
|
||||
],
|
||||
function(Backbone, GroupConfigurationModel) {
|
||||
'use strict';
|
||||
var GroupConfigurationCollection = Backbone.Collection.extend({
|
||||
model: GroupConfigurationModel
|
||||
});
|
||||
|
||||
return GroupConfigurationCollection;
|
||||
});
|
||||
29
cms/static/js/models/group.js
Normal file
29
cms/static/js/models/group.js
Normal file
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
});
|
||||
87
cms/static/js/models/group_configuration.js
Normal file
87
cms/static/js/models/group_configuration.js
Normal file
@@ -0,0 +1,87 @@
|
||||
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;
|
||||
});
|
||||
228
cms/static/js/spec/models/group_configuration_spec.js
Normal file
228
cms/static/js/spec/models/group_configuration_spec.js
Normal file
@@ -0,0 +1,228 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
145
cms/static/js/spec/views/group_configuration_spec.js
Normal file
145
cms/static/js/spec/views/group_configuration_spec.js
Normal file
@@ -0,0 +1,145 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
71
cms/static/js/spec/views/pages/group_configurations_spec.js
Normal file
71
cms/static/js/spec/views/pages/group_configurations_spec.js
Normal file
@@ -0,0 +1,71 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
54
cms/static/js/views/group_configuration_details.js
Normal file
54
cms/static/js/views/group_configuration_details.js
Normal file
@@ -0,0 +1,54 @@
|
||||
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;
|
||||
});
|
||||
36
cms/static/js/views/group_configurations_list.js
Normal file
36
cms/static/js/views/group_configurations_list.js
Normal file
@@ -0,0 +1,36 @@
|
||||
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;
|
||||
});
|
||||
40
cms/static/js/views/pages/group_configurations.js
Normal file
40
cms/static/js/views/pages/group_configurations.js
Normal file
@@ -0,0 +1,40 @@
|
||||
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)
|
||||
|
||||
160
cms/static/sass/views/_group-configuration.scss
Normal file
160
cms/static/sass/views/_group-configuration.scss
Normal file
@@ -0,0 +1,160 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
84
cms/templates/group_configurations.html
Normal file
84
cms/templates/group_configurations.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<%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">> </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 & 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>
|
||||
42
cms/templates/js/group-configuration-details.underscore
Normal file
42
cms/templates/js/group-configuration-details.underscore
Normal file
@@ -0,0 +1,42 @@
|
||||
<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>
|
||||
@@ -0,0 +1,22 @@
|
||||
<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">> </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>
|
||||
3
cms/templates/js/no-group-configurations.underscore
Normal file
3
cms/templates/js/no-group-configurations.underscore
Normal file
@@ -0,0 +1,3 @@
|
||||
<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 & 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 & 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)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user