Commit 246e0e6f by Tim Babych

Merge pull request #4411 from edx/anton/group-experiments

Create new group configurations page
parents 851fe47a d30e986a
......@@ -13,7 +13,7 @@ from django.conf import settings
from django.views.decorators.http import require_http_methods
from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.http import HttpResponseBadRequest, HttpResponseNotFound, HttpResponse
from util.json_request import JsonResponse
from edxmako.shortcuts import render_to_response
......@@ -21,6 +21,7 @@ from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
from xmodule.tabs import PDFTextbookTabs
from xmodule.partitions.partitions import UserPartition, Group
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from opaque_keys import InvalidKeyError
......@@ -67,7 +68,7 @@ __all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler'
'grading_handler',
'advanced_settings_handler',
'textbooks_list_handler', 'textbooks_detail_handler',
'group_configurations_list_handler']
'group_configurations_list_handler', 'group_configurations_detail_handler']
class AccessListFallback(Exception):
......@@ -857,7 +858,56 @@ def textbooks_detail_handler(request, course_key_string, textbook_id):
return JsonResponse()
@require_http_methods(("GET"))
class GroupConfigurationsValidationError(Exception):
"""
An error thrown when a group configurations input is invalid.
"""
pass
class GroupConfiguration(object):
"""
Prepare Group Configuration for the course.
"""
@staticmethod
def parse(configuration_json):
"""
Parse given string that represents group configuration.
"""
try:
group_configuration = json.loads(configuration_json)
except ValueError:
raise GroupConfigurationsValidationError(_("invalid JSON"))
if not group_configuration.get('version'):
group_configuration['version'] = UserPartition.VERSION
# this is temporary logic, we are going to build default groups on front-end
if not group_configuration.get('groups'):
group_configuration['groups'] = [
{'name': 'Group A'}, {'name': 'Group B'},
]
for group in group_configuration['groups']:
group['version'] = Group.VERSION
return group_configuration
@staticmethod
def validate(group_configuration):
"""
Validate group configuration representation.
"""
if not group_configuration.get("name"):
raise GroupConfigurationsValidationError(_("must have name of the configuration"))
if not isinstance(group_configuration.get("description"), basestring):
raise GroupConfigurationsValidationError(_("must have description of the configuration"))
if len(group_configuration.get('groups')) < 2:
raise GroupConfigurationsValidationError(_("must have at least two groups"))
group_id = unicode(group_configuration.get("id", ""))
if group_id and not group_id.isdigit():
raise GroupConfigurationsValidationError(_("group configuration ID must be numeric"))
@require_http_methods(("GET", "POST"))
@login_required
@ensure_csrf_cookie
def group_configurations_list_handler(request, course_key_string):
......@@ -866,17 +916,56 @@ def group_configurations_list_handler(request, course_key_string):
GET
html: return Group Configurations list page (Backbone application)
POST
json: create new group configuration
"""
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
store = modulestore()
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,
})
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key)
split_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 split_test_enabled else None,
})
elif "application/json" in request.META.get('HTTP_ACCEPT') and request.method == 'POST':
# create a new group configuration for the course
try:
configuration = GroupConfiguration.parse(request.body)
GroupConfiguration.validate(configuration)
except GroupConfigurationsValidationError as err:
return JsonResponse({"error": err.message}, status=400)
if not configuration.get("id"):
configuration["id"] = random.randint(100, 10**12)
# Assign ids to every group in configuration.
for index, group in enumerate(configuration.get('groups', [])):
group["id"] = index
course.user_partitions.append(UserPartition.from_json(configuration))
store.update_item(course, request.user.id)
response = JsonResponse(configuration, status=201)
response["Location"] = reverse_course_url(
'group_configurations_detail_handler',
course.id,
kwargs={'group_configuration_id': configuration["id"]}
)
return response
else:
return HttpResponse(status=406)
@require_http_methods(("GET", "POST"))
@login_required
@ensure_csrf_cookie
def group_configurations_detail_handler(request, course_key_string, group_configuration_id):
return JsonResponse(status=404)
def _get_course_creator_status(user):
......
import json
from unittest import skipUnless
from django.conf import settings
from contentstore.utils import reverse_course_url
from contentstore.tests.utils import CourseTestCase
@skipUnless(settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS'), 'Tests Group Configurations feature')
class GroupConfigurationsCreateTestCase(CourseTestCase):
"""
Test cases for creating a new group configurations.
"""
def setUp(self):
"""
Set up a url and group configuration content for tests.
"""
super(GroupConfigurationsCreateTestCase, self).setUp()
self.url = reverse_course_url('group_configurations_list_handler', self.course.id)
self.group_configuration_json = {
u'description': u'Test description',
u'name': u'Test name'
}
def test_index_page(self):
"""
Check that the group configuration index page responds correctly.
"""
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertIn('New Group Configuration', response.content)
def test_group_success(self):
"""
Test that you can create a group configuration.
"""
expected_group_configuration = {
u'description': u'Test description',
u'name': u'Test name',
u'version': 1,
u'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1},
{u'id': 1, u'name': u'Group B', u'version': 1}
]
}
response = self.client.post(
self.url,
data=json.dumps(self.group_configuration_json),
content_type="application/json",
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 201)
self.assertIn("Location", response)
group_configuration = json.loads(response.content)
del group_configuration['id'] # do not check for id, it is unique
self.assertEqual(expected_group_configuration, group_configuration)
def test_bad_group(self):
"""
Test if only one group in configuration exist.
"""
# Only one group in group configuration here.
bad_group_configuration = {
u'description': u'Test description',
u'id': 1,
u'name': u'Test name',
u'version': 1,
u'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1},
]
}
response = self.client.post(
self.url,
data=json.dumps(bad_group_configuration),
content_type="application/json",
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 400)
self.assertNotIn("Location", response)
content = json.loads(response.content)
self.assertIn("error", content)
def test_bad_configuration_id(self):
"""
Test if configuration id is not numeric.
"""
# Configuration id is string here.
bad_group_configuration = {
u'description': u'Test description',
u'id': 'bad_id',
u'name': u'Test name',
u'version': 1,
u'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1},
{u'id': 1, u'name': u'Group B', u'version': 1}
]
}
response = self.client.post(
self.url,
data=json.dumps(bad_group_configuration),
content_type="application/json",
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 400)
self.assertNotIn("Location", response)
content = json.loads(response.content)
self.assertIn("error", content)
def test_bad_json(self):
"""
Test bad json handling.
"""
bad_jsons = [
{u'name': 'Test Name'},
{u'description': 'Test description'},
{}
]
for bad_json in bad_jsons:
response = self.client.post(
self.url,
data=json.dumps(bad_json),
content_type="application/json",
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assertEqual(response.status_code, 400)
self.assertNotIn("Location", response)
content = json.loads(response.content)
self.assertIn("error", content)
def test_invalid_json(self):
"""
Test invalid json handling.
"""
# No property name.
invalid_json = "{u'name': 'Test Name', []}"
response = self.client.post(
self.url,
data=invalid_json,
content_type="application/json",
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assertEqual(response.status_code, 400)
self.assertNotIn("Location", response)
content = json.loads(response.content)
self.assertIn("error", content)
define([
'backbone', 'underscore', 'gettext', 'js/models/group',
'backbone', 'underscore', 'underscore.string', 'gettext', 'js/models/group',
'js/collections/group', 'backbone.associations', 'coffee/src/main'
],
function(Backbone, _, gettext, GroupModel, GroupCollection) {
function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
'use strict';
_.str = str;
var GroupConfiguration = Backbone.AssociatedModel.extend({
defaults: function() {
return {
id: null,
name: '',
description: '',
groups: new GroupCollection([{}, {}]),
showGroups: false
groups: new GroupCollection([]),
showGroups: false,
editing: false
};
},
......@@ -55,32 +56,12 @@ function(Backbone, _, gettext, GroupModel, GroupCollection) {
},
validate: function(attrs) {
if (!attrs.name) {
if (!_.str.trim(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'
'backbone', 'coffee/src/main', 'js/models/group_configuration',
'js/models/group', 'js/collections/group'
], function(
Backbone, GroupConfiguration, GroupConfigurationSet, Group, GroupSet, main
Backbone, main, GroupConfigurationModel, GroupModel, GroupCollection
) {
'use strict';
beforeEach(function() {
......@@ -14,10 +13,10 @@ define([
});
});
describe('GroupConfiguration model', function() {
describe('GroupConfigurationModel', function() {
beforeEach(function() {
main();
this.model = new GroupConfiguration();
this.model = new GroupConfigurationModel();
});
describe('Basic', function() {
......@@ -33,16 +32,10 @@ define([
expect(this.model.get('showGroups')).toBeFalsy();
});
it('should have a GroupSet with two groups by default', function() {
it('should be empty 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(groups).toBeInstanceOf(GroupCollection);
expect(this.model.isEmpty()).toBeTruthy();
});
......@@ -53,21 +46,23 @@ define([
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();
describe('should not be dirty', function () {
it('by default', function() {
expect(this.model.isDirty()).toBeFalsy();
});
it('after calling setOriginalAttributes', function() {
this.model.set('name', 'foobar');
this.model.setOriginalAttributes();
expect(this.model.isDirty()).toBeFalsy();
expect(this.model.isDirty()).toBeFalsy();
});
});
});
......@@ -96,7 +91,7 @@ define([
it('should match server model to client model', function() {
var serverModelSpec = {
'id': 10,
'name': 'My GroupConfiguration',
'name': 'My Group Configuration',
'description': 'Some description',
'groups': [
{
......@@ -108,9 +103,10 @@ define([
},
clientModelSpec = {
'id': 10,
'name': 'My GroupConfiguration',
'name': 'My Group Configuration',
'description': 'Some description',
'showGroups': false,
'editing': false,
'groups': [
{
'name': 'Group 1'
......@@ -119,7 +115,7 @@ define([
}
]
},
model = new GroupConfiguration(serverModelSpec);
model = new GroupConfigurationModel(serverModelSpec);
expect(deepAttributes(model)).toEqual(clientModelSpec);
expect(model.toJSON()).toEqual(serverModelSpec);
......@@ -128,55 +124,22 @@ define([
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]);
var model = new GroupConfigurationModel({ name: '' });
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]);
var model = new GroupConfigurationModel({ name: 'foo' });
expect(model.isValid()).toBeTruthy();
});
});
});
describe('Group model', function() {
describe('GroupModel', function() {
beforeEach(function() {
this.model = new Group();
this.model = new GroupModel();
});
describe('Basic', function() {
......@@ -191,22 +154,22 @@ define([
describe('Validation', function() {
it('requires a name', function() {
var model = new Group({ name: '' });
var model = new GroupModel({ name: '' });
expect(model.isValid()).toBeFalsy();
});
it('can pass validation', function() {
var model = new Group({ name: 'a' });
var model = new GroupModel({ name: 'a' });
expect(model.isValid()).toBeTruthy();
});
});
});
describe('Group collection', function() {
describe('GroupCollection', function() {
beforeEach(function() {
this.collection = new GroupSet();
this.collection = new GroupCollection();
});
it('is empty by default', function() {
......
......@@ -9,19 +9,32 @@ define([
),
noGroupConfigurationsTpl = readFixtures(
'no-group-configurations.underscore'
), view;
),
groupConfigurationEditTpl = readFixtures(
'group-configuration-edit.underscore'
);
var initializePage = function (disableSpy) {
view = new GroupConfigurationsPage({
el: $('.content-primary'),
var view = new GroupConfigurationsPage({
el: $('#content'),
collection: new GroupConfigurationCollection({
name: 'Configuration 1'
})
});
if (!disableSpy) {
spyOn(view, 'addGlobalActions');
spyOn(view, 'addWindowActions');
}
return view;
};
var renderPage = function () {
return initializePage().render();
};
var clickNewConfiguration = function (view) {
view.$('.nav-actions .new-button').click();
};
beforeEach(function () {
......@@ -29,12 +42,18 @@ define([
id: 'no-group-configurations-tpl',
type: 'text/template'
}).text(noGroupConfigurationsTpl));
appendSetFixtures($('<script>', {
id: 'group-configuration-edit-tpl',
type: 'text/template'
}).text(groupConfigurationEditTpl));
appendSetFixtures(mockGroupConfigurationsPage);
});
describe('Initial display', function() {
it('can render itself', function() {
initializePage();
var view = initializePage();
expect(view.$('.ui-loading')).toBeVisible();
view.render();
expect(view.$('.no-group-configurations-content')).toBeTruthy();
......@@ -45,9 +64,9 @@ define([
describe('on page close/change', function() {
it('I see notification message if the model is changed',
function() {
var message;
var view = initializePage(true),
message;
initializePage(true);
view.render();
message = view.onBeforeUnload();
expect(message).toBeUndefined();
......@@ -56,16 +75,23 @@ define([
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;
'You have unsaved changes. Do you really want to ',
'leave this page?'
].join(''),
view = renderPage(),
message;
initializePage();
view.render();
view.collection.at(0).set('name', 'Configuration 2');
message = view.onBeforeUnload();
expect(message).toBe(expectedMessage);
});
});
it('create new group configuration', function () {
var view = renderPage();
clickNewConfiguration(view);
expect($('.group-configuration-edit').length).toBeGreaterThan(0);
});
});
});
......@@ -4,13 +4,21 @@ define([
function(BaseView, _, gettext) {
'use strict';
var GroupConfigurationDetails = BaseView.extend({
tagName: 'section',
className: 'group-configuration',
tagName: 'div',
events: {
'click .show-groups': 'showGroups',
'click .hide-groups': 'hideGroups'
},
className: function () {
var index = this.model.collection.indexOf(this.model);
return [
'group-configuration-details',
'group-configuration-details-' + index
].join(' ');
},
initialize: function() {
this.template = _.template(
$('#group-configuration-details-tpl').text()
......@@ -41,7 +49,10 @@ function(BaseView, _, gettext) {
getGroupsCountTitle: function () {
var count = this.model.get('groups').length,
message = ngettext(
// Translators: 'count' is number of groups that the group configuration contains.
/*
Translators: 'count' is number of groups that the group
configuration contains.
*/
'Contains %(count)s group', 'Contains %(count)s groups',
count
);
......
define([
'js/views/baseview', 'underscore', 'jquery', 'gettext'
],
function(BaseView, _, $, gettext) {
'use strict';
var GroupConfigurationEdit = BaseView.extend({
tagName: 'div',
events: {
'change .group-configuration-name-input': 'setName',
'change .group-configuration-description-input': 'setDescription',
'focus .input-text': 'onFocus',
'blur .input-text': 'onBlur',
'submit': 'setAndClose',
'click .action-cancel': 'cancel'
},
className: function () {
var index = this.model.collection.indexOf(this.model);
return [
'group-configuration-edit',
'group-configuration-edit-' + index
].join(' ');
},
initialize: function() {
this.template = this.loadTemplate('group-configuration-edit');
this.listenTo(this.model, 'invalid', this.render);
},
render: function() {
this.$el.html(this.template({
id: this.model.get('id'),
uniqueId: _.uniqueId(),
name: this.model.escape('name'),
description: this.model.escape('description'),
isNew: this.model.isNew(),
error: this.model.validationError
}));
return this;
},
setName: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
this.model.set(
'name', this.$('.group-configuration-name-input').val(),
{ silent: true }
);
},
setDescription: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
this.model.set(
'description',
this.$('.group-configuration-description-input').val(),
{ silent: true }
);
},
setValues: function() {
this.setName();
this.setDescription();
return this;
},
setAndClose: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
this.setValues();
if(!this.model.isValid()) {
return false;
}
this.runOperationShowingMessage(
gettext('Saving') + '&hellip;',
function () {
var dfd = $.Deferred();
this.model.save({}, {
success: function() {
this.model.setOriginalAttributes();
this.close();
dfd.resolve();
}.bind(this)
});
return dfd;
}.bind(this)
);
},
cancel: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
this.model.reset();
return this.close();
},
close: function() {
var groupConfigurations = this.model.collection;
this.remove();
if(this.model.isNew()) {
// if the group configuration has never been saved, remove it
groupConfigurations.remove(this.model);
} else {
// tell the model that it's no longer being edited
this.model.set('editing', false);
}
return this;
}
});
return GroupConfigurationEdit;
});
define([
'js/views/baseview', 'jquery', 'js/views/group_configuration_details',
'js/views/group_configuration_edit'
], function(
BaseView, $, GroupConfigurationDetails, GroupConfigurationEdit
) {
'use strict';
var GroupConfigurationsItem = BaseView.extend({
tagName: 'section',
attributes: {
'tabindex': -1
},
className: function () {
var index = this.model.collection.indexOf(this.model);
return [
'group-configuration',
'group-configurations-list-item',
'group-configurations-list-item-' + index
].join(' ');
},
initialize: function() {
this.listenTo(this.model, 'change:editing', this.render);
this.listenTo(this.model, 'remove', this.remove);
},
render: function() {
// Removes a view from the DOM, and calls stopListening to remove
// any bound events that the view has listened to.
if (this.view) {
this.view.remove();
}
if (this.model.get('editing')) {
this.view = new GroupConfigurationEdit({
model: this.model
});
} else {
this.view = new GroupConfigurationDetails({
model: this.model
});
}
this.$el.html(this.view.render().el);
return this;
}
});
return GroupConfigurationsItem;
});
define(['js/views/baseview', 'jquery', 'js/views/group_configuration_details'],
function(BaseView, $, GroupConfigurationDetailsView) {
define([
'js/views/baseview', 'jquery', 'js/views/group_configuration_item'
], function(
BaseView, $, GroupConfigurationItemView
) {
'use strict';
var GroupConfigurationsList = BaseView.extend({
tagName: 'div',
className: 'group-configurations-list',
events: { },
events: {
'click .new-button': 'addOne'
},
initialize: function() {
this.emptyTemplate = this.loadTemplate('no-group-configurations');
this.listenTo(this.collection, 'all', this.render);
this.listenTo(this.collection, 'add', this.addNewItemView);
},
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({
var view = new GroupConfigurationItemView({
model: configuration
});
......@@ -29,6 +35,27 @@ function(BaseView, $, GroupConfigurationDetailsView) {
this.$el.html([frag]);
}
return this;
},
addNewItemView: function (model) {
var view = new GroupConfigurationItemView({
model: model
});
// If items already exist, just append one new. Otherwise, overwrite
// no-content message.
if (this.collection.length > 1) {
this.$el.append(view.render().el);
} else {
this.$el.html(view.render().el);
}
view.$el.focus();
},
addOne: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
this.collection.add([{editing: true}]);
}
});
......
......@@ -2,23 +2,30 @@ define([
'jquery', 'underscore', 'gettext', 'js/views/baseview',
'js/views/group_configurations_list'
],
function ($, _, gettext, BaseView, ConfigurationsListView) {
function ($, _, gettext, BaseView, GroupConfigurationsList) {
'use strict';
var GroupConfigurationsPage = BaseView.extend({
initialize: function() {
BaseView.prototype.initialize.call(this);
this.listView = new ConfigurationsListView({
this.listView = new GroupConfigurationsList({
collection: this.collection
});
},
render: function() {
this.hideLoadingIndicator();
this.$el.append(this.listView.render().el);
this.addGlobalActions();
this.$('.content-primary').append(this.listView.render().el);
this.addButtonActions();
this.addWindowActions();
},
addGlobalActions: function () {
addButtonActions: function () {
this.$('.nav-actions .new-button').click(function (event) {
this.listView.addOne(event);
}.bind(this));
},
addWindowActions: function () {
$(window).on('beforeunload', this.onBeforeUnload.bind(this));
},
......
......@@ -27,11 +27,21 @@
color: $gray;
}
.new-button {
@include font-size(14);
margin-left: $baseline;
[class^="icon-"] {
margin-right: ($baseline/2);
}
}
.group-configuration {
@extend %ui-window;
position: relative;
outline: none;
.view-group-configuration {
.group-configuration-details {
padding: $baseline ($baseline*1.5);
.group-configuration-header {
......@@ -60,6 +70,7 @@
}
.group-configuration-info {
@extend %t-copy-sub1;
color: $gray-l1;
margin-left: $baseline;
......@@ -152,6 +163,182 @@
opacity: 1.0;
}
}
.group-configuration-edit {
@include box-sizing(border-box);
border-radius: 2px;
width: 100%;
background: $white;
.wrapper-form {
padding: $baseline ($baseline*1.5);
}
.tip {
@extend %t-copy-sub2;
@include transition(color, 0.15s, ease-in-out);
display: block;
margin-top: ($baseline/4);
color: $gray-l3;
}
.is-focused .tip{
color: $gray;
}
.actions {
box-shadow: inset 0 1px 2px $shadow;
border-top: 1px solid $gray-l1;
padding: ($baseline*0.75) $baseline;
background: $gray-l6;
.action {
margin-right: ($baseline/4);
&:last-child {
margin-right: 0;
}
}
// add a group is below with groups styling
.action-primary {
@include blue-button;
@extend %t-action2;
@include transition(all .15s);
display: inline-block;
padding: ($baseline/5) $baseline;
font-weight: 600;
text-transform: uppercase;
}
.action-secondary {
@include grey-button;
@extend %t-action2;
@include transition(all .15s);
display: inline-block;
padding: ($baseline/5) $baseline;
font-weight: 600;
text-transform: uppercase;
}
}
.copy {
@extend %t-copy-sub2;
margin: ($baseline) 0 ($baseline/2) 0;
color: $gray;
strong {
font-weight: 600;
}
}
.group-configuration-fields {
@extend %cont-no-list;
.field {
margin: 0 0 ($baseline*0.75) 0;
&:last-child {
margin-bottom: 0;
}
&.required {
label {
font-weight: 600;
}
label:after {
margin-left: ($baseline/4);
content: "*";
}
}
label, input, textarea {
display: block;
}
textarea {
resize: vertical;
}
label {
@extend %t-copy-sub1;
@include transition(color, 0.15s, ease-in-out);
margin: 0 0 ($baseline/4) 0;
&.is-focused {
color: $blue;
}
}
//this section is borrowed from _account.scss - we should clean up and unify later
input, textarea {
@extend %t-copy-base;
height: 100%;
width: 100%;
padding: ($baseline/2);
&.long {
width: 100%;
}
&.short {
width: 25%;
}
::-webkit-input-placeholder {
color: $gray-l4;
}
:-moz-placeholder {
color: $gray-l3;
}
::-moz-placeholder {
color: $gray-l3;
}
:-ms-input-placeholder {
color: $gray-l3;
}
&:focus {
+ .tip {
color: $gray;
}
}
}
&.error {
label {
color: $red;
}
input {
border-color: $red;
}
}
&.add-group-configuration-name label {
@extend %t-title5;
}
}
label.required {
font-weight: 600;
&:after {
margin-left: ($baseline/4);
content: "*";
}
}
}
.group-configuration-fields {
margin-bottom: $baseline;
}
}
}
.content-supplementary {
......
......@@ -11,7 +11,7 @@
<%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"]:
% for template_name in ["group-configuration-details", "group-configuration-edit", "no-group-configurations", "basic-modal", "modal-button"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
......@@ -20,14 +20,16 @@
<%block name="jsextra">
<script type="text/javascript">
require(["!domReady", "js/collections/group_configuration", "js/views/pages/group_configurations"],
function(doc, GroupConfigurationCollection, GroupConfigurationsPage, xmoduleLoader) {
require(["domReady!", "js/collections/group_configuration", "js/views/pages/group_configurations"],
function(doc, GroupConfigurationCollection, GroupConfigurationsPage) {
% if configurations is not None:
var view = new GroupConfigurationsPage({
el: $('.content-primary'),
collection: new GroupConfigurationCollection(${json.dumps(configurations)}, { url: "${group_configuration_url}" })
});
view.render();
var collection = new GroupConfigurationCollection(${json.dumps(configurations)});
collection.url = "${group_configuration_url}";
new GroupConfigurationsPage({
el: $('#content'),
collection: collection
}).render();
% endif
});
</script>
......@@ -40,6 +42,14 @@ function(doc, GroupConfigurationCollection, GroupConfigurationsPage, xmoduleLoad
<small class="subtitle">${_("Settings")}</small>
<span class="sr">&gt; </span>${_("Group Configurations")}
</h1>
<nav class="nav-actions">
<h3 class="sr">${_("Page Actions")}</h3>
<ul>
<li class="nav-item">
<a href="#" class="button new-button"><i class="icon-plus"></i> ${_("New Group Configuration")}</a>
</li>
</ul>
</nav>
</header>
</div>
......
<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>
<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
<ol class="group-configuration-info group-configuration-info-<% if(showGroups){ print('block'); } else { print('inline'); } %>">
<% if (!_.isUndefined(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>
<% } %>
<% 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>
<form class="group-configuration-edit-form">
<div class="wrapper-form">
<% if (error && error.message) { %>
<div class="group-configuration-edit-error message message-status message-status error is-shown" name="group-configuration-edit-error">
<%= gettext(error.message) %>
</div>
<% } %>
<fieldset class="group-configuration-fields">
<legend class="sr"><%= gettext("Group Configuration information") %></legend>
<div class="input-wrap field text required add-group-configuration-name <% if(error && error.attributes && error.attributes.name) { print('error'); } %>">
<label for="group-configuration-name-<%= uniqueId %>"><%= gettext("Group Configuration Name") %></label>
<input id="group-configuration-name-<%= uniqueId %>" class="group-configuration-name-input input-text" name="group-configuration-name" type="text" placeholder="<%= gettext("This is the Name of the Group Configuration") %>" value="<%= name %>">
<span class="tip tip-stacked"><%= gettext("Name or short description of the configuration") %></span>
</div>
<div class="input-wrap field text add-group-configuration-description">
<label for="group-configuration-description-<%= uniqueId %>"><%= gettext("Description") %></label>
<textarea id="group-configuration-description-<%= uniqueId %>" class="group-configuration-description-input text input-text" name="group-configuration-description" placeholder="<%= gettext("This is the Description of the Group Configuration") %>"><%= description %></textarea>
<span class="tip tip-stacked"><%= gettext("Optional long description") %></span>
</div>
</fieldset>
</div>
<div class="actions">
<button class="action action-primary" type="submit"><% if (isNew) { print(gettext("Create")) } else { print(gettext("Save")) } %></button>
<button class="action action-secondary action-cancel"><%= gettext("Cancel") %></button>
</div>
</form>
<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>
<nav class="nav-actions">
<h3 class="sr">${_("Page Actions")}</h3>
<ul>
<li class="nav-item">
<a href="#" class="button new-button"><i class="icon-plus"></i> ${_("New Group Configuration")}</a>
</li>
</ul>
</nav>
</header>
</div>
......
<div class="no-group-configurations-content">
<p><%= gettext("You haven't created any group configurations yet.") %></p>
<p><%= gettext("You haven't created any group configurations yet.") %><a href="#" class="button new-button"><i class="icon-plus"></i><%= gettext("Add your first Group Configuration") %></a></p>
</div>
......@@ -93,8 +93,11 @@ urlpatterns += patterns(
)
if settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS'):
urlpatterns += (url(r'^group_configurations/(?P<course_key_string>[^/]+)$',
'contentstore.views.group_configurations_list_handler'),)
urlpatterns += patterns('contentstore.views',
url(r'^group_configurations/(?P<course_key_string>[^/]+)$', 'group_configurations_list_handler'),
url(r'^group_configurations/(?P<course_key_string>[^/]+)/(?P<group_configuration_id>\d+)/?$',
'group_configurations_detail_handler'),
)
js_info_dict = {
'domain': 'djangojs',
......
......@@ -19,9 +19,15 @@ class GroupConfigurationsPage(CoursePage):
"""
Returns list of the group configurations for the course.
"""
css = '.wrapper-group-configuration'
css = '.group-configurations-list-item'
return [GroupConfiguration(self, index) for index in xrange(len(self.q(css=css)))]
def create(self):
"""
Creates new group configuration.
"""
self.q(css=".new-button").first.click()
class GroupConfiguration(object):
"""
......@@ -30,7 +36,7 @@ class GroupConfiguration(object):
def __init__(self, page, index):
self.page = page
self.SELECTOR = '.view-group-configuration-{}'.format(index)
self.SELECTOR = '.group-configurations-list-item-{}'.format(index)
self.index = index
def get_selector(self, css=''):
......@@ -49,6 +55,31 @@ class GroupConfiguration(object):
css = 'a.group-toggle'
self.find_css(css).first.click()
def save(self):
"""
Save group configuration.
"""
css = '.action-primary'
self.find_css(css).first.click()
self.page.wait_for_ajax()
def cancel(self):
"""
Cancel group configuration.
"""
css = '.action-secondary'
self.find_css(css).first.click()
@property
def mode(self):
"""
Returns group configuration mode.
"""
if self.find_css('.group-configuration-edit').present:
return 'edit'
elif self.find_css('.group-configuration-details').present:
return 'details'
@property
def id(self):
"""
......@@ -58,6 +89,14 @@ class GroupConfiguration(object):
return self.find_css(css).first.text[0]
@property
def validation_message(self):
"""
Returns validation message.
"""
css = '.message-status.error'
return self.find_css(css).first.text[0]
@property
def name(self):
"""
Returns group configuration name.
......@@ -65,6 +104,14 @@ class GroupConfiguration(object):
css = '.group-configuration-title'
return self.find_css(css).first.text[0]
@name.setter
def name(self, value):
"""
Sets group configuration name.
"""
css = '.group-configuration-name-input'
self.find_css(css).first.fill(value)
@property
def description(self):
"""
......@@ -73,6 +120,14 @@ class GroupConfiguration(object):
css = '.group-configuration-description'
return self.find_css(css).first.text[0]
@description.setter
def description(self, value):
"""
Sets group configuration description.
"""
css = '.group-configuration-description-input'
self.find_css(css).first.fill(value)
@property
def groups(self):
"""
......
......@@ -11,7 +11,7 @@ from ..pages.studio.component_editor import ComponentEditorView
from ..pages.studio.utils import add_discussion
from unittest import skip
from bok_choy.promise import Promise
class ContainerBase(UniqueCourseTest):
"""
......@@ -93,6 +93,30 @@ class ContainerBase(UniqueCourseTest):
break
self.assertEqual(len(blocks_checked), len(xblocks))
def verify_groups(self, container, active_groups, inactive_groups):
"""
Check that the groups appear and are correctly categorized as to active and inactive.
Also checks that the "add missing groups" button/link is not present unless a value of False is passed
for verify_missing_groups_not_present.
"""
def wait_for_xblocks_to_render():
# First xblock is the container for the page, subtract 1.
return (len(active_groups) + len(inactive_groups) == len(container.xblocks) - 1, len(active_groups))
Promise(wait_for_xblocks_to_render, "Number of xblocks on the page are incorrect").fulfill()
def check_xblock_names(expected_groups, actual_blocks):
self.assertEqual(len(expected_groups), len(actual_blocks))
for idx, expected in enumerate(expected_groups):
self.assertEqual('Expand or Collapse\n{}'.format(expected), actual_blocks[idx].name)
check_xblock_names(active_groups, container.active_xblocks)
check_xblock_names(inactive_groups, container.inactive_xblocks)
# Verify inactive xblocks appear after active xblocks
check_xblock_names(active_groups + inactive_groups, container.xblocks[1:])
def do_action_and_verify(self, action, expected_ordering):
"""
Perform the supplied action and then verify the resulting ordering.
......
......@@ -58,29 +58,7 @@ class SplitTest(ContainerBase):
self.user = course_fix.user
def verify_groups(self, container, active_groups, inactive_groups, verify_missing_groups_not_present=True):
"""
Check that the groups appear and are correctly categorized as to active and inactive.
Also checks that the "add missing groups" button/link is not present unless a value of False is passed
for verify_missing_groups_not_present.
"""
def wait_for_xblocks_to_render():
# First xblock is the container for the page, subtract 1.
return (len(active_groups) + len(inactive_groups) == len(container.xblocks) - 1, len(active_groups))
Promise(wait_for_xblocks_to_render, "Number of xblocks on the page are incorrect").fulfill()
def check_xblock_names(expected_groups, actual_blocks):
self.assertEqual(len(expected_groups), len(actual_blocks))
for idx, expected in enumerate(expected_groups):
self.assertEqual('Expand or Collapse\n{}'.format(expected), actual_blocks[idx].name)
check_xblock_names(active_groups, container.active_xblocks)
check_xblock_names(inactive_groups, container.inactive_xblocks)
# Verify inactive xblocks appear after active xblocks
check_xblock_names(active_groups + inactive_groups, container.xblocks[1:])
super(SplitTest, self).verify_groups(container, active_groups, inactive_groups)
if verify_missing_groups_not_present:
self.verify_add_missing_groups_button_not_present(container)
......@@ -231,33 +209,33 @@ class SettingsMenuTest(UniqueCourseTest):
@skipUnless(os.environ.get('FEATURE_GROUP_CONFIGURATIONS'), 'Tests Group Configurations feature')
class GroupConfigurationsTest(UniqueCourseTest):
class GroupConfigurationsTest(ContainerBase):
"""
Tests that Group Configurations page works correctly with previously
added configurations in Studio
"""
__test__ = True
def setUp(self):
super(GroupConfigurationsTest, self).setUp()
def setup_fixtures(self):
course_fix = CourseFixture(**self.course_info)
course_fix.add_advanced_settings({
u"advanced_modules": {"value": ["split_test"]},
})
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit')
)
)
).install()
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.course_fix = course_fix
self.user = course_fix.user
def setUp(self):
super(GroupConfigurationsTest, self).setUp()
self.page = GroupConfigurationsPage(
self.browser,
self.course_info['org'],
......@@ -298,6 +276,7 @@ class GroupConfigurationsTest(UniqueCourseTest):
config = self.page.group_configurations()[0]
self.assertIn("Name of the Group Configuration", config.name)
self.assertEqual(config.id, '0')
# Expand the configuration
config.toggle()
self.assertIn("Description of the group configuration.", config.description)
self.assertEqual(len(config.groups), 2)
......@@ -308,8 +287,111 @@ class GroupConfigurationsTest(UniqueCourseTest):
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
# Expand the configuration
config.toggle()
self.assertEqual(len(config.groups), 3)
self.assertEqual("Beta", config.groups[1].name)
self.assertEqual("33%", config.groups[1].allocation)
def test_can_create_group_configuration(self):
"""
Ensure that the group configuration can be created correctly.
"""
self.page.visit()
self.assertEqual(len(self.page.group_configurations()), 0)
# Create new group configuration
self.page.create()
config = self.page.group_configurations()[0]
config.name = "New Group Configuration Name"
config.description = "New Description of the group configuration."
# Save the configuration
config.save()
self.assertEqual(config.mode, 'details')
self.assertIn("New Group Configuration Name", config.name)
self.assertTrue(config.id)
# Expand the configuration
config.toggle()
self.assertIn("New Description of the group configuration.", config.description)
self.assertEqual(len(config.groups), 2)
self.assertEqual("Group A", config.groups[0].name)
self.assertEqual("Group B", config.groups[1].name)
self.assertEqual("50%", config.groups[0].allocation)
def test_use_group_configuration(self):
"""
Create and use group configuration
"""
self.page.visit()
self.assertEqual(len(self.page.group_configurations()), 0)
# Create new group configuration
self.page.create()
config = self.page.group_configurations()[0]
config.name = "New Group Configuration Name"
config.description = "New Description of the group configuration."
# Save the configuration
config.save()
unit = self.go_to_unit_page(make_draft=True)
add_advanced_component(unit, 0, 'split_test')
container = self.go_to_container_page()
container.edit()
component_editor = ComponentEditorView(self.browser, container.locator)
component_editor.set_select_value_and_save('Group Configuration', 'New Group Configuration Name')
self.verify_groups(container, ['Group A', 'Group B'], [])
def test_can_cancel_creation_of_group_configuration(self):
"""
Ensure that creation of the group configuration can be canceled correctly.
"""
self.page.visit()
self.assertEqual(len(self.page.group_configurations()), 0)
# Create new group configuration
self.page.create()
config = self.page.group_configurations()[0]
config.name = "Name of the Group Configuration"
config.description = "Description of the group configuration."
# Cancel the configuration
config.cancel()
self.assertEqual(len(self.page.group_configurations()), 0)
def test_group_configuration_validation(self):
"""
Ensure that validation of the group configuration works correctly.
"""
self.page.visit()
# Create new group configuration
self.page.create()
# Leave empty required field
config = self.page.group_configurations()[0]
config.description = "Description of the group configuration."
# Try to save
config.save()
# Verify that configuration is still in editing mode
self.assertEqual(config.mode, 'edit')
# Verify error message
self.assertEqual(
"Group Configuration name is required",
config.validation_message
)
# Set required field
config.name = "Name of the Group Configuration"
# Save the configuration
config.save()
# Verify the configuration is saved and it is shown in `details` mode.
self.assertEqual(config.mode, 'details')
# Verify the configuration for the data correctness
self.assertIn("Name of the Group Configuration", config.name)
self.assertTrue(config.id)
# Expand the configuration
config.toggle()
self.assertIn("Description of the group configuration.", config.description)
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