Commit fff84928 by Daniel Friedman Committed by Andy Armstrong

Create and edit content groups in Studio

parent e44ca91b
......@@ -16,6 +16,7 @@ from django.core.urlresolvers import reverse
from django.http import HttpResponseBadRequest, HttpResponseNotFound, HttpResponse, Http404
from util.json_request import JsonResponse, JsonResponseBadRequest
from util.date_utils import get_default_time_display
from util.db import generate_int_id, MYSQL_MAX_INT
from edxmako.shortcuts import render_to_response
from xmodule.course_module import DEFAULT_START_DATE
......@@ -29,6 +30,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseErr
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locations import Location
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition
from django_future.csrf import ensure_csrf_cookie
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update
......@@ -70,7 +72,15 @@ from course_action_state.models import CourseRerunState, CourseRerunUIStateManag
from course_action_state.managers import CourseActionStateItemNotFoundError
from microsite_configuration import microsite
from xmodule.course_module import CourseFields
from xmodule.split_test_module import get_split_user_partitions
MINIMUM_GROUP_ID = 100
# Note: the following content group configuration strings are not
# translated since they are not visible to users.
CONTENT_GROUP_CONFIGURATION_DESCRIPTION = 'The groups in this configuration can be mapped to cohort groups in the LMS.'
CONTENT_GROUP_CONFIGURATION_NAME = 'Content Group Configuration'
__all__ = ['course_info_handler', 'course_handler', 'course_listing',
'course_info_update_handler',
......@@ -1252,23 +1262,16 @@ class GroupConfiguration(object):
if len(self.configuration.get('groups', [])) < 1:
raise GroupConfigurationsValidationError(_("must have at least one group"))
def generate_id(self, used_ids):
"""
Generate unique id for the group configuration.
If this id is already used, we generate new one.
"""
cid = random.randint(100, 10 ** 12)
while cid in used_ids:
cid = random.randint(100, 10 ** 12)
return cid
def assign_id(self, configuration_id=None):
"""
Assign id for the json representation of group configuration.
"""
self.configuration['id'] = int(configuration_id) if configuration_id else self.generate_id(self.get_used_ids())
if configuration_id:
self.configuration['id'] = int(configuration_id)
else:
self.configuration['id'] = generate_int_id(
MINIMUM_GROUP_ID, MYSQL_MAX_INT, GroupConfiguration.get_used_ids(self.course)
)
def assign_group_ids(self):
"""
......@@ -1278,14 +1281,15 @@ class GroupConfiguration(object):
# Assign ids to every group in configuration.
for group in self.configuration.get('groups', []):
if group.get('id') is None:
group["id"] = self.generate_id(used_ids)
group["id"] = generate_int_id(MINIMUM_GROUP_ID, MYSQL_MAX_INT, used_ids)
used_ids.append(group["id"])
def get_used_ids(self):
@staticmethod
def get_used_ids(course):
"""
Return a list of IDs that already in use.
"""
return set([p.id for p in self.course.user_partitions])
return set([p.id for p in course.user_partitions])
def get_user_partition(self):
"""
......@@ -1296,21 +1300,19 @@ class GroupConfiguration(object):
@staticmethod
def get_usage_info(course, store):
"""
Get usage information for all Group Configurations.
Get usage information for all Group Configurations currently referenced by a split_test instance.
"""
split_tests = store.get_items(course.id, qualifiers={'category': 'split_test'})
return GroupConfiguration._get_usage_info(store, course, split_tests)
@staticmethod
def add_usage_info(course, store):
def get_split_test_partitions_with_usage(course, store):
"""
Add usage information to group configurations jsons in course.
Returns json of group configurations updated with usage information.
Returns json split_test group configurations updated with usage information.
"""
usage_info = GroupConfiguration.get_usage_info(course, store)
configurations = []
for partition in course.user_partitions:
for partition in get_split_user_partitions(course.user_partitions):
configuration = partition.to_json()
configuration['usage'] = usage_info.get(partition.id, [])
configurations.append(configuration)
......@@ -1384,6 +1386,26 @@ class GroupConfiguration(object):
configuration_json['usage'] = usage_information.get(configuration.id, [])
return configuration_json
@staticmethod
def get_or_create_content_group_configuration(course):
"""
Returns the first user partition from the course which uses the
CohortPartitionScheme, or generates one if no such partition is
found. The created partition is not saved to the course until
the client explicitly creates a group within the partition and
POSTs back.
"""
content_group_configuration = get_cohorted_user_partition(course.id)
if content_group_configuration is None:
content_group_configuration = UserPartition(
id=generate_int_id(MINIMUM_GROUP_ID, MYSQL_MAX_INT, GroupConfiguration.get_used_ids(course)),
name=CONTENT_GROUP_CONFIGURATION_NAME,
description=CONTENT_GROUP_CONFIGURATION_DESCRIPTION,
groups=[],
scheme_id='cohort'
)
return content_group_configuration
@require_http_methods(("GET", "POST"))
@login_required
......@@ -1405,12 +1427,21 @@ def group_configurations_list_handler(request, course_key_string):
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key)
course_outline_url = reverse_course_url('course_handler', course_key)
configurations = GroupConfiguration.add_usage_info(course, store)
should_show_experiment_groups = are_content_experiments_enabled(course)
if should_show_experiment_groups:
experiment_group_configurations = GroupConfiguration.get_split_test_partitions_with_usage(course, store)
else:
experiment_group_configurations = None
content_group_configuration = GroupConfiguration.get_or_create_content_group_configuration(
course
).to_json()
return render_to_response('group_configurations.html', {
'context_course': course,
'group_configuration_url': group_configuration_url,
'course_outline_url': course_outline_url,
'configurations': configurations,
'experiment_group_configurations': experiment_group_configurations,
'should_show_experiment_groups': should_show_experiment_groups,
'content_group_configuration': content_group_configuration
})
elif "application/json" in request.META.get('HTTP_ACCEPT'):
if request.method == 'POST':
......@@ -1489,6 +1520,16 @@ def group_configurations_detail_handler(request, course_key_string, group_config
return JsonResponse(status=204)
def are_content_experiments_enabled(course):
"""
Returns True if content experiments have been enabled for the course.
"""
return (
SPLIT_TEST_COMPONENT_TYPE in ADVANCED_COMPONENT_TYPES and
SPLIT_TEST_COMPONENT_TYPE in course.advanced_modules
)
def _get_course_creator_status(user):
"""
Helper method for returning the course creator status for a particular user,
......
......@@ -207,6 +207,7 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'First name')
self.assertContains(response, 'Group C')
self.assertContains(response, 'Content Group Configuration')
def test_unsupported_http_accept_header(self):
"""
......@@ -232,12 +233,9 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
{u'name': u'Group B', u'version': 1},
],
}
response = self.client.post(
response = self.client.ajax_post(
self._url(),
data=json.dumps(GROUP_CONFIGURATION_JSON),
content_type="application/json",
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
data=GROUP_CONFIGURATION_JSON
)
self.assertEqual(response.status_code, 201)
self.assertIn("Location", response)
......@@ -256,6 +254,16 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
self.assertEqual(user_partititons[0].groups[0].name, u'Group A')
self.assertEqual(user_partititons[0].groups[1].name, u'Group B')
def test_lazily_creates_cohort_configuration(self):
"""
Test that a cohort schemed user partition is NOT created by
default for the user.
"""
self.assertEqual(len(self.course.user_partitions), 0)
self.client.get(self._url())
self.reload_course()
self.assertEqual(len(self.course.user_partitions), 0)
# pylint: disable=no-member
class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfigurationsBaseTestCase, HelperMethods):
......@@ -425,7 +433,7 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
Test that right data structure will be created if group configuration is not used.
"""
self._add_user_partitions()
actual = GroupConfiguration.add_usage_info(self.course, self.store)
actual = GroupConfiguration.get_split_test_partitions_with_usage(self.course, self.store)
expected = [{
'id': 0,
'name': 'Name 0',
......@@ -449,7 +457,7 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
vertical, __ = self._create_content_experiment(cid=0, name_suffix='0')
self._create_content_experiment(name_suffix='1')
actual = GroupConfiguration.add_usage_info(self.course, self.store)
actual = GroupConfiguration.get_split_test_partitions_with_usage(self.course, self.store)
expected = [{
'id': 0,
......@@ -492,7 +500,7 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
vertical, __ = self._create_content_experiment(cid=0, name_suffix='0')
vertical1, __ = self._create_content_experiment(cid=0, name_suffix='1')
actual = GroupConfiguration.add_usage_info(self.course, self.store)
actual = GroupConfiguration.get_split_test_partitions_with_usage(self.course, self.store)
expected = [{
'id': 0,
......@@ -556,7 +564,7 @@ class GroupConfigurationsValidationTestCase(CourseTestCase, HelperMethods):
validation.add(mocked_message)
mocked_validation_messages.return_value = validation
group_configuration = GroupConfiguration.add_usage_info(self.course, self.store)[0]
group_configuration = GroupConfiguration.get_split_test_partitions_with_usage(self.course, self.store)[0]
self.assertEqual(expected_result.to_json(), group_configuration['usage'][0]['validation'])
def test_error_message_present(self):
......
......@@ -28,6 +28,7 @@ class CourseMetadata(object):
'graded',
'hide_from_toc',
'pdf_textbooks',
'user_partitions',
'name', # from xblock
'tags', # from xblock
'visible_to_staff_only',
......
from openedx.core.djangoapps.course_groups.partition_scheme import CohortPartitionScheme
"""
Tests for the Studio authoring XBlock mixin.
"""
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.partitions.partitions import Group, UserPartition
......@@ -40,6 +43,7 @@ class AuthoringMixinTestCase(ModuleStoreTestCase):
"""
Create a cohorted user partition with the specified content groups.
"""
# pylint: disable=attribute-defined-outside-init
self.content_partition = UserPartition(
1,
'Content Groups',
......@@ -60,7 +64,7 @@ class AuthoringMixinTestCase(ModuleStoreTestCase):
Set group_access for the specified item to the specified group
ids within the content partition.
"""
item.group_access[self.content_partition.id] = group_ids
item.group_access[self.content_partition.id] = group_ids # pylint: disable=no-member
self.store.update_item(item, self.user.id)
def verify_visibility_view_contains(self, item, substrings):
......
define([
'js/collections/group_configuration', 'js/views/pages/group_configurations'
], function(GroupConfigurationCollection, GroupConfigurationsPage) {
'js/collections/group_configuration', 'js/models/group_configuration', 'js/views/pages/group_configurations'
], function(GroupConfigurationCollection, GroupConfigurationModel, GroupConfigurationsPage) {
'use strict';
return function (configurations, groupConfigurationUrl, courseOutlineUrl) {
var collection = new GroupConfigurationCollection(configurations, { parse: true }),
configurationsPage;
return function (experimentsEnabled, experimentGroupConfigurationsJson, contentGroupConfigurationJson,
groupConfigurationUrl, courseOutlineUrl) {
var experimentGroupConfigurations = new GroupConfigurationCollection(
experimentGroupConfigurationsJson, {parse: true}
),
contentGroupConfiguration = new GroupConfigurationModel(contentGroupConfigurationJson, {parse: true});
collection.url = groupConfigurationUrl;
collection.outlineUrl = courseOutlineUrl;
configurationsPage = new GroupConfigurationsPage({
experimentGroupConfigurations.url = groupConfigurationUrl;
experimentGroupConfigurations.outlineUrl = courseOutlineUrl;
contentGroupConfiguration.urlRoot = groupConfigurationUrl;
new GroupConfigurationsPage({
el: $('#content'),
collection: collection
experimentsEnabled: experimentsEnabled,
experimentGroupConfigurations: experimentGroupConfigurations,
contentGroupConfiguration: contentGroupConfiguration
}).render();
};
});
define([
'jquery', 'underscore', 'js/views/pages/group_configurations',
'js/collections/group_configuration', 'js/common_helpers/template_helpers'
], function ($, _, GroupConfigurationsPage, GroupConfigurationCollection, TemplateHelpers) {
'js/models/group_configuration', 'js/collections/group_configuration',
'js/common_helpers/template_helpers'
], function ($, _, GroupConfigurationsPage, GroupConfigurationModel, GroupConfigurationCollection, TemplateHelpers) {
'use strict';
describe('GroupConfigurationsPage', function() {
var mockGroupConfigurationsPage = readFixtures(
'mock/mock-group-configuration-page.underscore'
),
itemClassName = '.group-configurations-list-item';
groupConfigItemClassName = '.group-configurations-list-item';
var initializePage = function (disableSpy) {
var view = new GroupConfigurationsPage({
el: $('#content'),
collection: new GroupConfigurationCollection({
experimentsEnabled: true,
experimentGroupConfigurations: new GroupConfigurationCollection({
id: 0,
name: 'Configuration 1'
})
}),
contentGroupConfiguration: new GroupConfigurationModel({groups: []})
});
if (!disableSpy) {
......@@ -29,15 +32,11 @@ define([
return initializePage().render();
};
var clickNewConfiguration = function (view) {
view.$('.nav-actions .new-button').click();
};
beforeEach(function () {
setFixtures(mockGroupConfigurationsPage);
TemplateHelpers.installTemplates([
'no-group-configurations', 'group-configuration-edit',
'group-configuration-details'
'group-configuration-editor', 'group-configuration-details', 'content-group-details',
'content-group-editor', 'group-edit', 'list'
]);
this.addMatchers({
......@@ -52,69 +51,67 @@ define([
var view = initializePage();
expect(view.$('.ui-loading')).toBeVisible();
view.render();
expect(view.$(itemClassName)).toExist();
expect(view.$(groupConfigItemClassName)).toExist();
expect(view.$('.cohort-groups .no-group-configurations-content')).toExist();
expect(view.$('.ui-loading')).toHaveClass('is-hidden');
});
});
describe('on page close/change', function() {
it('I see notification message if the model is changed',
function() {
var view = initializePage(true),
message;
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?',
view = renderPage(),
message;
view.collection.at(0).set('name', 'Configuration 2');
message = view.onBeforeUnload();
expect(message).toBe(expectedMessage);
});
});
describe('Check that Group Configuration will focus and expand depending on content of url hash', function() {
describe('Experiment group configurations', function() {
beforeEach(function () {
spyOn($.fn, 'focus');
TemplateHelpers.installTemplate('group-configuration-details');
this.view = initializePage(true);
});
it('should focus and expand group configuration if its id is part of url hash', function() {
it('should focus and expand if its id is part of url hash', function() {
spyOn(this.view, 'getLocationHash').andReturn('#0');
this.view.render();
// We cannot use .toBeFocused due to flakiness.
expect($.fn.focus).toHaveBeenCalled();
expect(this.view.$(itemClassName)).toBeExpanded();
expect(this.view.$(groupConfigItemClassName)).toBeExpanded();
});
it('should not focus on any group configuration if url hash is empty', function() {
it('should not focus on any experiment configuration if url hash is empty', function() {
spyOn(this.view, 'getLocationHash').andReturn('');
this.view.render();
expect($.fn.focus).not.toHaveBeenCalled();
expect(this.view.$(itemClassName)).not.toBeExpanded();
expect(this.view.$(groupConfigItemClassName)).not.toBeExpanded();
});
it('should not focus on any group configuration if url hash contains wrong id', function() {
it('should not focus on any experiment configuration if url hash contains wrong id', function() {
spyOn(this.view, 'getLocationHash').andReturn('#1');
this.view.render();
expect($.fn.focus).not.toHaveBeenCalled();
expect(this.view.$(itemClassName)).not.toBeExpanded();
expect(this.view.$(groupConfigItemClassName)).not.toBeExpanded();
});
it('should not show a notification message if an experiment configuration is not changed', function () {
this.view.render();
expect(this.view.onBeforeUnload()).toBeUndefined();
});
it('should show a notification message if an experiment configuration is changed', function () {
this.view.experimentGroupConfigurations.at(0).set('name', 'Configuration 2');
expect(this.view.onBeforeUnload())
.toBe('You have unsaved changes. Do you really want to leave this page?');
});
});
it('create new group configuration', function () {
var view = renderPage();
describe('Content groups', function() {
beforeEach(function() {
this.view = renderPage();
});
it('should not show a notification message if a content group is not changed', function () {
expect(this.view.onBeforeUnload()).toBeUndefined();
});
clickNewConfiguration(view);
expect($('.group-configuration-edit').length).toBeGreaterThan(0);
it('should show a notification message if a content group is changed', function () {
this.view.contentGroupConfiguration.get('groups').add({name: 'Content Group'});
expect(this.view.onBeforeUnload())
.toBe('You have unsaved changes. Do you really want to leave this page?');
});
});
});
});
/**
* This class defines a simple display view for a content group.
* It is expected to be backed by a Group model.
*/
define([
'js/views/baseview'
], function(BaseView) {
'use strict';
var ContentGroupDetailsView = BaseView.extend({
tagName: 'div',
className: 'group-configuration-details',
events: {
'click .edit': 'editGroup'
},
editGroup: function() {
this.model.set({'editing': true});
},
initialize: function() {
this.template = this.loadTemplate('content-group-details');
},
render: function() {
this.$el.html(this.template(this.model.toJSON()));
}
});
return ContentGroupDetailsView;
});
/**
* This class defines an editing view for content groups.
* It is expected to be backed by a Group model.
*/
define([
'js/views/list_item_editor', 'underscore'
],
function(ListItemEditorView, _) {
'use strict';
var ContentGroupEditorView = ListItemEditorView.extend({
tagName: 'div',
className: 'group-configuration-edit',
events: {
'submit': 'setAndClose',
'click .action-cancel': 'cancel'
},
initialize: function() {
ListItemEditorView.prototype.initialize.call(this);
this.template = this.loadTemplate('content-group-editor');
},
getTemplateOptions: function() {
return {
name: this.model.escape('name'),
index: this.model.collection.indexOf(this.model),
isNew: this.model.isNew(),
uniqueId: _.uniqueId()
};
},
setValues: function() {
this.model.set({name: this.$('input').val().trim()});
return this;
},
getSaveableModel: function() {
return this.model.collection.parents[0];
}
});
return ContentGroupEditorView;
});
/**
* This class defines an controller view for content groups.
* It renders an editor view or a details view depending on the state
* of the underlying model.
* It is expected to be backed by a Group model.
*/
define([
'js/views/list_item', 'js/views/content_group_editor', 'js/views/content_group_details'
], function(ListItemView, ContentGroupEditorView, ContentGroupDetailsView) {
'use strict';
var ContentGroupItemView = ListItemView.extend({
tagName: 'section',
createEditView: function() {
return new ContentGroupEditorView({model: this.model});
},
createDetailsView: function() {
return new ContentGroupDetailsView({model: this.model});
}
});
return ContentGroupItemView;
});
/**
* This class defines a list view for content groups.
* It is expected to be backed by a Group collection.
*/
define([
'js/views/list', 'js/views/content_group_item', 'gettext'
], function(ListView, ContentGroupItemView, gettext) {
'use strict';
var ContentGroupListView = ListView.extend({
tagName: 'div',
className: 'content-group-list',
// Translators: This refers to a content group that can be linked to a student cohort.
itemCategoryDisplayName: gettext('content group'),
createItemView: function(options) {
return new ContentGroupItemView(options);
}
});
return ContentGroupListView;
});
/**
* This class defines an edit view for groups within content experiment group configurations.
* It is expected to be backed by a Group model.
*/
define([
'js/views/baseview', 'underscore', 'underscore.string', 'jquery', 'gettext'
'js/views/baseview', 'underscore', 'underscore.string', 'gettext'
],
function(BaseView, _, str, $, gettext) {
function(BaseView, _, str, gettext) {
'use strict';
_.str = str; // used in template
var GroupEdit = BaseView.extend({
var ExperimentGroupEditView = BaseView.extend({
tagName: 'li',
events: {
'click .action-close': 'removeGroup',
......@@ -38,7 +42,7 @@ function(BaseView, _, str, $, gettext) {
},
changeName: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
if (event && event.preventDefault) { event.preventDefault(); }
this.model.set({
name: this.$('.group-name').val()
}, { silent: true });
......@@ -47,7 +51,7 @@ function(BaseView, _, str, $, gettext) {
},
removeGroup: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
if (event && event.preventDefault) { event.preventDefault(); }
this.model.collection.remove(this.model);
return this.remove();
},
......@@ -65,5 +69,5 @@ function(BaseView, _, str, $, gettext) {
}
});
return GroupEdit;
return ExperimentGroupEditView;
});
/**
* This class defines a details view for content experiment group configurations.
* It is expected to be instantiated with a GroupConfiguration model.
*/
define([
'js/views/baseview', 'underscore', 'gettext', 'underscore.string'
],
function(BaseView, _, gettext, str) {
'use strict';
var GroupConfigurationDetails = BaseView.extend({
var GroupConfigurationDetailsView = BaseView.extend({
tagName: 'div',
events: {
'click .edit': 'editConfiguration',
......@@ -40,17 +44,17 @@ function(BaseView, _, gettext, str) {
},
editConfiguration: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
if (event && event.preventDefault) { event.preventDefault(); }
this.model.set('editing', true);
},
showGroups: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
if (event && event.preventDefault) { event.preventDefault(); }
this.model.set('showGroups', true);
},
hideGroups: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
if (event && event.preventDefault) { event.preventDefault(); }
this.model.set('showGroups', false);
},
......@@ -107,5 +111,5 @@ function(BaseView, _, gettext, str) {
}
});
return GroupConfigurationDetails;
return GroupConfigurationDetailsView;
});
/**
* This class defines an editing view for content experiment group configurations.
* It is expected to be backed by a GroupConfiguration model.
*/
define([
'js/views/baseview', 'underscore', 'jquery', 'gettext',
'js/views/group_edit', 'js/views/utils/view_utils'
'js/views/list_item_editor', 'underscore', 'jquery', 'gettext',
'js/views/experiment_group_edit'
],
function(BaseView, _, $, gettext, GroupEdit, ViewUtils) {
function(ListItemEditorView, _, $, gettext, ExperimentGroupEditView) {
'use strict';
var GroupConfigurationEdit = BaseView.extend({
var GroupConfigurationEditorView = ListItemEditorView.extend({
tagName: 'div',
events: {
'change .group-configuration-name-input': 'setName',
'change .group-configuration-description-input': 'setDescription',
"click .action-add-group": "createGroup",
'click .action-add-group': 'createGroup',
'focus .input-text': 'onFocus',
'blur .input-text': 'onBlur',
'submit': 'setAndClose',
......@@ -26,43 +30,50 @@ function(BaseView, _, $, gettext, GroupEdit, ViewUtils) {
},
initialize: function() {
var groups;
var groups = this.model.get('groups');
this.template = this.loadTemplate('group-configuration-edit');
this.listenTo(this.model, 'invalid', this.render);
groups = this.model.get('groups');
this.listenTo(groups, 'add', this.addOne);
ListItemEditorView.prototype.initialize.call(this);
this.template = this.loadTemplate('group-configuration-editor');
this.listenTo(groups, 'add', this.onAddItem);
this.listenTo(groups, 'reset', this.addAll);
this.listenTo(groups, 'all', this.render);
},
render: function() {
this.$el.html(this.template({
ListItemEditorView.prototype.render.call(this);
this.addAll();
return this;
},
getTemplateOptions: function() {
return {
id: this.model.get('id'),
uniqueId: _.uniqueId(),
name: this.model.escape('name'),
description: this.model.escape('description'),
usage: this.model.get('usage'),
isNew: this.model.isNew(),
error: this.model.validationError
}));
this.addAll();
return this;
isNew: this.model.isNew()
};
},
getSaveableModel: function() {
return this.model;
},
addOne: function(group) {
var view = new GroupEdit({ model: group });
onAddItem: function(group) {
var view = new ExperimentGroupEditView({ model: group });
this.$('ol.groups').append(view.render().el);
return this;
},
addAll: function() {
this.model.get('groups').each(this.addOne, this);
this.model.get('groups').each(this.onAddItem, this);
},
createGroup: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
if (event && event.preventDefault) { event.preventDefault(); }
var collection = this.model.get('groups');
collection.add([{
name: collection.getNextDefaultGroupName(),
......@@ -71,7 +82,7 @@ function(BaseView, _, $, gettext, GroupEdit, ViewUtils) {
},
setName: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
if (event && event.preventDefault) { event.preventDefault(); }
this.model.set(
'name', this.$('.group-configuration-name-input').val(),
{ silent: true }
......@@ -79,7 +90,7 @@ function(BaseView, _, $, gettext, GroupEdit, ViewUtils) {
},
setDescription: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
if (event && event.preventDefault) { event.preventDefault(); }
this.model.set(
'description',
this.$('.group-configuration-description-input').val(),
......@@ -94,7 +105,7 @@ function(BaseView, _, $, gettext, GroupEdit, ViewUtils) {
_.each(this.$('.groups li'), function(li, i) {
var group = this.model.get('groups').at(i);
if(group) {
if (group) {
group.set({
'name': $('.group-name', li).val()
});
......@@ -102,56 +113,8 @@ function(BaseView, _, $, gettext, GroupEdit, ViewUtils) {
}, this);
return this;
},
setAndClose: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
this.setValues();
if(!this.model.isValid()) {
return false;
}
ViewUtils.runOperationShowingMessage(
gettext('Saving'),
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;
return GroupConfigurationEditorView;
});
/**
* This class defines an controller view for content experiment group configurations.
* It renders an editor view or a details view depending on the state
* of the underlying model.
* It is expected to be backed by a Group model.
*/
define([
'js/views/baseview', 'jquery', "gettext", 'js/views/group_configuration_details',
'js/views/group_configuration_edit', "js/views/utils/view_utils"
'js/views/list_item', 'js/views/group_configuration_details', 'js/views/group_configuration_editor', 'gettext'
], function(
BaseView, $, gettext, GroupConfigurationDetails, GroupConfigurationEdit, ViewUtils
ListItemView, GroupConfigurationDetailsView, GroupConfigurationEditorView, gettext
) {
'use strict';
var GroupConfigurationsItem = BaseView.extend({
var GroupConfigurationItemView = ListItemView.extend({
events: {
'click .delete': 'deleteItem'
},
tagName: 'section',
attributes: function () {
return {
'id': this.model.get('id'),
'tabindex': -1
};
},
events: {
'click .delete': 'deleteConfiguration'
},
className: function () {
var index = this.model.collection.indexOf(this.model);
// Translators: this refers to a collection of groups.
itemDisplayName: gettext('group configuration'),
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);
},
canDelete: true,
deleteConfiguration: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
var self = this;
ViewUtils.confirmThenRunOperation(
gettext('Delete this Group Configuration?'),
gettext('Deleting this Group Configuration is permanent and cannot be undone.'),
gettext('Delete'),
function() {
return ViewUtils.runOperationShowingMessage(
gettext('Deleting'),
function () {
return self.model.destroy({ wait: true });
}
);
}
);
createEditView: function() {
return new GroupConfigurationEditorView({model: this.model});
},
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;
createDetailsView: function() {
return new GroupConfigurationDetailsView({model: this.model});
}
});
return GroupConfigurationsItem;
return GroupConfigurationItemView;
});
/**
* This class defines a list view for content experiment group configurations.
* It is expected to be backed by a GroupConfiguration collection.
*/
define([
'js/views/baseview', 'jquery', 'js/views/group_configuration_item'
], function(
BaseView, $, GroupConfigurationItemView
) {
'js/views/list', 'js/views/group_configuration_item', 'gettext'
], function(ListView, GroupConfigurationItemView, gettext) {
'use strict';
var GroupConfigurationsList = BaseView.extend({
tagName: 'div',
className: 'group-configurations-list',
events: {
'click .new-button': 'addOne'
},
initialize: function() {
this.emptyTemplate = this.loadTemplate('no-group-configurations');
this.listenTo(this.collection, 'add', this.addNewItemView);
this.listenTo(this.collection, 'remove', this.handleDestory);
},
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 GroupConfigurationItemView({
model: configuration
});
frag.appendChild(view.render().el);
});
this.$el.html([frag]);
}
return this;
},
addNewItemView: function (model) {
var view = new GroupConfigurationItemView({
model: model
});
var GroupConfigurationsListView = ListView.extend({
tagName: 'div',
// 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);
}
className: 'group-configurations-list',
view.$el.focus();
},
newModelOptions: {addDefaultGroups: true},
addOne: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
this.collection.add([{ editing: true }]);
},
// Translators: this refers to a collection of groups.
itemCategoryDisplayName: gettext('group configuration'),
handleDestory: function () {
if(this.collection.length === 0) {
this.$el.html(this.emptyTemplate());
}
createItemView: function(options) {
return new GroupConfigurationItemView(options);
}
});
return GroupConfigurationsList;
return GroupConfigurationsListView;
});
/**
* A generic list view class.
*
* Expects the following properties to be overriden:
* render when the collection is empty.
* - createItemView (function): Create and return an item view for a
* model in the collection.
* - newModelOptions (object): Options to pass to models which are
* added to the collection.
* - itemCategoryDisplayName (string): Display name for the category
* of items this list contains. For example, 'Group Configuration'.
* Note that it must be translated.
*/
define([
'js/views/baseview'
], function(BaseView) {
'use strict';
var ListView = BaseView.extend({
events: {
'click .action-add': 'onAddItem',
'click .new-button': 'onAddItem'
},
initialize: function() {
this.listenTo(this.collection, 'add', this.addNewItemView);
this.listenTo(this.collection, 'remove', this.onRemoveItem);
this.template = this.loadTemplate('list');
// Don't render the add button when editing a form
this.listenTo(this.collection, 'change:editing', this.toggleAddButton);
this.listenTo(this.collection, 'add', this.toggleAddButton);
this.listenTo(this.collection, 'remove', this.toggleAddButton);
},
render: function(model) {
this.$el.html(this.template({
itemCategoryDisplayName: this.itemCategoryDisplayName,
length: this.collection.length,
isEditing: model && model.get('editing')
}));
this.collection.each(function(model) {
this.$('.content-groups').append(this.createItemView({model: model}).render().el);
}, this);
return this;
},
hideOrShowAddButton: function(shouldShow) {
var addButtonCss = '.action-add';
if (this.collection.length) {
if (shouldShow) {
this.$(addButtonCss).removeClass('is-hidden');
} else {
this.$(addButtonCss).addClass('is-hidden');
}
}
},
toggleAddButton: function(model) {
if (model.get('editing') && this.collection.contains(model)) {
this.hideOrShowAddButton(false);
} else {
this.hideOrShowAddButton(true);
}
},
addNewItemView: function (model) {
var view = this.createItemView({model: model});
// If items already exist, just append one new.
// Otherwise re-render the empty list HTML.
if (this.collection.length > 1) {
this.$('.content-groups').append(view.render().el);
} else {
this.render();
}
view.$el.focus();
},
onAddItem: function(event) {
if (event && event.preventDefault) { event.preventDefault(); }
this.collection.add({editing: true}, this.newModelOptions);
},
onRemoveItem: function () {
if (this.collection.length === 0) {
this.render();
}
}
});
return ListView;
});
/**
* A generic view to represent an editable item in a list. The item
* has a edit view and a details view.
*
* Subclasses must implement:
* - itemDisplayName (string): Display name for the list item.
* Must be translated.
* - createEditView (function): Render and append the edit view to the
* DOM.
* - createDetailsView (function): Render and append the details view
* to the DOM.
*/
define([
'js/views/baseview', 'jquery', "gettext", "js/views/utils/view_utils"
], function(
BaseView, $, gettext, ViewUtils
) {
'use strict';
var ListItemView = BaseView.extend({
canDelete: false,
initialize: function() {
this.listenTo(this.model, 'change:editing', this.render);
this.listenTo(this.model, 'remove', this.remove);
},
deleteItem: function(event) {
if (event && event.preventDefault) { event.preventDefault(); }
if (!this.canDelete) { return; }
var model = this.model,
itemDisplayName = this.itemDisplayName;
ViewUtils.confirmThenRunOperation(
interpolate(
// Translators: "item_display_name" is the name of the item to be deleted.
gettext('Delete this %(item_display_name)s?'),
{item_display_name: itemDisplayName}, true
),
interpolate(
// Translators: "item_display_name" is the name of the item to be deleted.
gettext('Deleting this %(item_display_name)s is permanent and cannot be undone.'),
{item_display_name: itemDisplayName},
true
),
gettext('Delete'),
function() {
return ViewUtils.runOperationShowingMessage(
gettext('Deleting'),
function () {
return model.destroy({wait: true});
}
);
}
);
},
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 = this.createEditView();
} else {
this.view = this.createDetailsView();
}
this.$el.html(this.view.render().el);
return this;
}
});
return ListItemView;
});
/**
* A generic view to represent a list item in its editing state.
*
* Subclasses must implement:
* - getTemplateOptions (function): Return an object to pass to the
* template.
* - setValues (function): Set values on the model according to the
* DOM.
* - getSaveableModel (function): Return the model which should be
* saved by this view. Note this may be a parent model.
*/
define([
'js/views/baseview', 'js/views/utils/view_utils', 'underscore', 'gettext'
], function(BaseView, ViewUtils, _, gettext) {
'use strict';
var ListItemEditorView = BaseView.extend({
initialize: function() {
this.listenTo(this.model, 'invalid', this.render);
},
render: function() {
this.$el.html(this.template(_.extend({
error: this.model.validationError
}, this.getTemplateOptions())));
},
setAndClose: function(event) {
if (event && event.preventDefault) { event.preventDefault(); }
this.setValues();
if (!this.model.isValid()) {
return false;
}
ViewUtils.runOperationShowingMessage(
gettext('Saving'),
function () {
var dfd = $.Deferred();
var actionableModel = this.getSaveableModel();
actionableModel.save({}, {
success: function() {
actionableModel.setOriginalAttributes();
this.close();
dfd.resolve();
}.bind(this)
});
return dfd;
}.bind(this));
},
cancel: function(event) {
if (event && event.preventDefault) { event.preventDefault(); }
this.getSaveableModel().reset();
return this.close();
},
close: function() {
this.remove();
if (this.model.isNew() && !_.isUndefined(this.model.collection)) {
// if the item has never been saved, remove it
this.model.collection.remove(this.model);
} else {
// tell the model that it's no longer being edited
this.model.set('editing', false);
}
return this;
}
});
return ListItemEditorView;
});
define([
'jquery', 'underscore', 'gettext', 'js/views/pages/base_page',
'js/views/group_configurations_list'
'js/views/group_configurations_list', 'js/views/content_group_list'
],
function ($, _, gettext, BasePage, GroupConfigurationsList) {
function ($, _, gettext, BasePage, GroupConfigurationsListView, ContentGroupListView) {
'use strict';
var GroupConfigurationsPage = BasePage.extend({
initialize: function() {
initialize: function(options) {
BasePage.prototype.initialize.call(this);
this.listView = new GroupConfigurationsList({
collection: this.collection
this.experimentsEnabled = options.experimentsEnabled;
if (this.experimentsEnabled) {
this.experimentGroupConfigurations = options.experimentGroupConfigurations;
this.experimentGroupsListView = new GroupConfigurationsListView({
collection: this.experimentGroupConfigurations
});
}
this.contentGroupConfiguration = options.contentGroupConfiguration;
this.cohortGroupsListView = new ContentGroupListView({
collection: this.contentGroupConfiguration.get('groups')
});
},
renderPage: function() {
var hash = this.getLocationHash();
this.$('.content-primary').append(this.listView.render().el);
this.addButtonActions();
if (this.experimentsEnabled) {
this.$('.experiment-groups').append(this.experimentGroupsListView.render().el);
}
this.$('.cohort-groups').append(this.cohortGroupsListView.render().el);
this.addWindowActions();
if (hash) {
// Strip leading '#' to get id string to match
......@@ -24,22 +34,17 @@ function ($, _, gettext, BasePage, GroupConfigurationsList) {
return $.Deferred().resolve().promise();
},
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));
},
onBeforeUnload: function () {
var dirty = this.collection.find(function(configuration) {
return configuration.isDirty();
});
var dirty = this.contentGroupConfiguration.isDirty() ||
(this.experimentsEnabled && this.experimentGroupConfigurations.find(function(configuration) {
return configuration.isDirty();
}));
if(dirty) {
if (dirty) {
return gettext('You have unsaved changes. Do you really want to leave this page?');
}
},
......@@ -57,7 +62,7 @@ function ($, _, gettext, BasePage, GroupConfigurationsList) {
* @param {String|Number} Id of the group configuration.
*/
expandConfiguration: function (id) {
var groupConfig = this.collection.findWhere({
var groupConfig = this.experimentsEnabled && this.experimentGroupConfigurations.findWhere({
id: parseInt(id)
});
......
......@@ -253,7 +253,7 @@
color: $gray-l3;
}
.is-focused .tip{
.is-focused .tip {
color: $gray;
}
......@@ -274,12 +274,12 @@
// add a group is below with groups styling
.action-primary {
@extend %btn-primary-blue;
padding: ($baseline*.3) $baseline;
padding: ($baseline/4) $baseline;
}
.action-secondary {
@extend %btn-secondary-gray;
padding: ($baseline*.3) $baseline;
padding: ($baseline/4) $baseline;
}
.wrapper-delete-button {
......@@ -495,6 +495,25 @@
padding: ($baseline/2);
}
}
// add/new items
.action-add {
@extend %ui-btn-flat-outline;
display: block;
width: 100%;
margin-top: ($baseline*0.75);
padding: ($baseline/2) $baseline;
&.is-hidden {
display: none;
}
.icon {
display: inline-block;
vertical-align: middle;
@include margin-right($baseline/2);
}
}
}
.content-supplementary {
......
<%inherit file="base.html" />
<%def name="online_help_token()"><% return "group_configurations" %></%def>
<%def name="content_groups_help_token()"><% return "content_groups" %></%def>
<%def name="experiment_group_configurations_help_token()"><% return "group_configurations" %></%def>
<%namespace name='static' file='static_content.html'/>
<%! import json %>
<%!
......@@ -11,7 +12,7 @@
<%block name="bodyclass">is-signedin course view-group-configurations</%block>
<%block name="header_extras">
% for template_name in ["group-configuration-details", "group-configuration-edit", "no-group-configurations", "group-edit", "basic-modal", "modal-button"]:
% for template_name in ["group-configuration-details", "group-configuration-editor", "group-edit", "content-group-editor", "content-group-details", "basic-modal", "modal-button", "list"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
......@@ -19,11 +20,9 @@
</%block>
<%block name="requirejs">
% if configurations is not None:
require(["js/factories/group_configurations"], function(GroupConfigurationsFactory) {
GroupConfigurationsFactory(${json.dumps(configurations)}, "${group_configuration_url}", "${course_outline_url}");
GroupConfigurationsFactory(${json.dumps(should_show_experiment_groups)}, ${json.dumps(experiment_group_configurations)}, ${json.dumps(content_group_configuration)}, "${group_configuration_url}", "${course_outline_url}");
});
% endif
</%block>
<%block name="content">
......@@ -33,32 +32,23 @@
<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 fa fa-plus"></i> ${_("New Group Configuration")}</a>
</li>
</ul>
</nav>
</header>
</div>
<div class="wrapper-content wrapper">
<section class="content">
<article class="content-primary" role="main">
<div class="wrapper-groups cohort-groups">
<h3 class="title">${_("Cohorted Content")}</h3>
<p class="copy">${_("A cohorted content group configuration allows different groups of students to view separate content. You can then define what units each cohort can see from the {a_start}Course Outline{a_end}. [Copy TBD]").format(a_start='<a href="">', a_end="</a>")}</p>
<div class="no-group-configurations-content">
<p>${_("You haven't created any cohort groups yet.")}<a href="#" class="button new-button"><i class="icon fa fa-plus"></i> ${_("Add your first Cohort Group")}</a></p>
<div class="wrapper-groups content-groups">
<h3 class="title">${_("Content Groups")}</h3>
<div class="ui-loading">
<p><span class="spin"><i class="icon fa fa-refresh"></i></span> <span class="copy">${_("Loading")}</span></p>
</div>
</div>
</div>
% if should_show_experiment_groups:
<div class="wrapper-groups experiment-groups">
<h3 class="title">${_("Experiment Groups")}</h3>
<p class="copy">${_("An experiment group configuration defines how many groups of students are in an experiment. You can then add a content experiment to any unit in the {a_start}Course Outline{a_end}. [Copy TBD]").format(a_start='<a href="">', a_end="</a>")}</p>
% if configurations is None:
<h3 class="title">${_("Experiment Group Configurations")}</h3>
% if experiment_group_configurations is None:
<div class="notice notice-incontext notice-moduledisabled">
<p class="copy">
${_("This module is disabled at the moment.")}
......@@ -70,20 +60,28 @@
</div>
% endif
</div>
% endif
</article>
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">${_("What can I do on this page?")}</h3>
<p>${_("You can create, edit, and delete group configurations.")}</p>
<p>${_("A group configuration defines how many groups of students are in an experiment. When you create an experiment, you select the group configuration to use.")}</p>
<p>${_("Click {em_start}New Group Configuration{em_end} to add a new configuration. To edit a configuration, hover over its box and click {em_start}Edit{em_end}.").format(em_start='<strong>', em_end="</strong>")}</p>
<p>${_("You can delete a group configuration only if it is not in use in an experiment. To delete a configuration, hover over its box and click the delete icon.")}</p>
<p><a href="${get_online_help_info(online_help_token())['doc_url']}" target="_blank">${_("Learn More")}</a></p>
</div>
<div class="content-groups-doc">
<div class="bit">
<h3 class="title-3">${_("Content Groups")}</h3>
<p>${_("Use content groups to give groups of students access to a specific set of course content. In addition to course content that is intended for all students, each content group sees content that you specifically designate as visible to it. By associating a content group with one or more cohorts, you can customize the content that a particular cohort or cohorts sees in your course.")}</p>
<p>${_("Click {em_start}New content group{em_end} to add a new content group. To edit the name of a content group, hover over its box and click {em_start}Edit{em_end}. Content groups cannot be deleted.").format(em_start="<strong>", em_end="</strong>")}</p>
<p><a href="${get_online_help_info(content_groups_help_token())['doc_url']}" target="_blank" class="button external-help-button">${_("Learn More")}</a></p>
</div>
</div>
% if should_show_experiment_groups:
<div class="experiment-groups-doc">
<div class="bit">
<h3 class="title-3">${_("Experiment Group Configurations")}</h3>
<p>${_("Use experiment group configurations to define how many groups of students are in a content experiment. When you create a content experiment for a course, you select the group configuration to use.")}</p>
<p>${_("Click {em_start}New Group Configuration{em_end} to add a new configuration. To edit a configuration, hover over its box and click {em_start}Edit{em_end}.").format(em_start="<strong>", em_end="</strong>")}</p>
<p>${_("You can delete a group configuration only if it is not in use in an experiment. To delete a configuration, hover over its box and click the delete icon.")}</p>
<p><a href="${get_online_help_info(experiment_group_configurations_help_token())['doc_url']}" target="_blank" class="button external-help-button">${_("Learn More")}</a></p>
</div>
</div>
% endif
<div class="bit">
% if context_course:
<%
......
<div class="wrapper-group-configuration">
<header class="group-configuration-header">
<h3 class="group-configuration-title">
<%= name %>
</h3>
</header>
<ul class="actions group-configuration-actions">
<li class="action action-edit">
<button class="edit"><i class="icon fa fa-pencil"></i> <%= gettext("Edit") %></button>
</li>
</ul>
</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="groups-fields">
<div class="input-wrap field text required add-group-configuration-name <% if(error && error.attributes && error.attributes.name) { print('error'); } %>">
<label for="group-cohort-name-<%= uniqueId %>"><%= gettext("Content Group Name") %></label>
<input name="group-cohort-name" id="group-cohort-name-<%= uniqueId %>" class="group-configuration-name-input input-text" value="<%- name %>" type="text" placeholder="<%= gettext("This is the name of the group") %>">
</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>
......@@ -42,7 +42,7 @@
<% } %>
<ul class="actions group-configuration-actions">
<li class="action action-edit">
<button class="edit"><i class="icon icon-pencil"></i> <%= gettext("Edit") %></button>
<button class="edit"><i class="icon fa fa-pencil"></i> <%= gettext("Edit") %></button>
</li>
<% if (_.isEmpty(usage)) { %>
<li class="action action-delete wrapper-delete-button">
......
<% if (length === 0) { %>
<div class="no-group-configurations-content">
<p><%- interpolate(gettext("You have not created any %(item_type)ss yet."), {item_type: itemCategoryDisplayName}, true) %><a href="#" class="button new-button"><i class="icon fa fa-plus"></i> <%= gettext("Add your first content group") %></a></p>
</div>
<% } else { %>
<div class="content-groups"></div>
<% if (!isEditing) { %>
<button class="action action-add">
<i class="icon fa fa-plus"></i><%- interpolate(gettext('New %(item_type)s'), {item_type: itemCategoryDisplayName}, true) %>
</button>
<% } %>
<% } %>
......@@ -3,24 +3,23 @@
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">Settings</small>
<span class="sr">&gt; </span>Group Configurations
<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 fa fa-plus"></i> New Group Configuration</a>
</li>
</ul>
</nav>
</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 fa fa-refresh"></i></span> <span class="copy">Loading</span></p>
<div class="wrapper-groups cohort-groups">
<div class="ui-loading">
<p><span class="spin"><i class="icon fa fa-refresh"></i></span> <span class="copy">Loading</span></p>
</div>
</div>
<div class="wrapper-groups experiment-groups">
<div class="ui-loading">
<p><span class="spin"><i class="icon fa fa-refresh"></i></span> <span class="copy">Loading</span></p>
</div>
</div>
</article>
<aside class="content-supplementary" role="complementary"></aside>
......
<div class="no-group-configurations-content">
<p><%= gettext("You haven't created any group configurations yet.") %><a href="#" class="button new-button"><i class="icon fa fa-plus"></i><%= gettext("Add your first Experiment Configuration") %></a></p>
</div>
......@@ -2,10 +2,14 @@
Utility functions related to databases.
"""
from functools import wraps
import random
from django.db import connection, transaction
MYSQL_MAX_INT = (2 ** 31) - 1
def commit_on_success_with_read_committed(func): # pylint: disable=invalid-name
"""
Decorator which executes the decorated function inside a transaction with isolation level set to READ COMMITTED.
......@@ -38,3 +42,18 @@ def commit_on_success_with_read_committed(func): # pylint: disable=invalid-name
return func(*args, **kwargs)
return wrapper
def generate_int_id(minimum=0, maximum=MYSQL_MAX_INT, used_ids=None):
"""
Return a unique integer in the range [minimum, maximum], inclusive.
"""
if used_ids is None:
used_ids = []
cid = random.randint(minimum, maximum)
while cid in used_ids:
cid = random.randint(minimum, maximum)
return cid
......@@ -8,9 +8,9 @@ import unittest
from django.contrib.auth.models import User
from django.db import connection, IntegrityError
from django.db.transaction import commit_on_success, TransactionManagementError
from django.test import TransactionTestCase
from django.test import TestCase, TransactionTestCase
from util.db import commit_on_success_with_read_committed
from util.db import commit_on_success_with_read_committed, generate_int_id
@ddt.ddt
......@@ -99,3 +99,31 @@ class TransactionIsolationLevelsTestCase(TransactionTestCase):
with commit_on_success():
with commit_on_success():
commit_on_success_with_read_committed(do_nothing)()
@ddt.ddt
class GenerateIntIdTestCase(TestCase):
"""Tests for `generate_int_id`"""
@ddt.data(10)
def test_no_used_ids(self, times):
"""
Verify that we get a random integer within the specified range
when there are no used ids.
"""
minimum = 1
maximum = times
for i in range(times):
self.assertIn(generate_int_id(minimum, maximum), range(minimum, maximum + 1))
@ddt.data(10)
def test_used_ids(self, times):
"""
Verify that we get a random integer within the specified range
but not in a list of used ids.
"""
minimum = 1
maximum = times
used_ids = {2, 4, 6, 8}
for i in range(times):
int_id = generate_int_id(minimum, maximum, used_ids)
self.assertIn(int_id, list(set(range(minimum, maximum + 1)) - used_ids))
......@@ -567,10 +567,16 @@ class DiscussionTabHomePage(CoursePage, DiscussionPageMixin):
@property
def new_post_button(self):
"""
Returns the new post button.
"""
elements = self.q(css="ol.course-tabs .new-post-btn")
return elements.first if elements.visible and len(elements) == 1 else None
@property
def new_post_form(self):
"""
Returns the new post form.
"""
elements = self.q(css=".forum-new-post-form")
return elements[0] if elements.visible and len(elements) == 1 else None
......@@ -262,6 +262,7 @@ class MembershipPageCohortManagementSection(PageObject):
"""
self.q(css=self._bounded_selector(".cohort-management-settings li.tab-settings>a")).first.click()
# pylint: disable=redefined-builtin
def get_cohort_settings_messages(self, type="confirmation", wait_for_messages=True):
"""
Returns an array of messages related to modifying cohort settings. If wait_for_messages
......
......@@ -41,15 +41,15 @@ class GroupConfigurationsPage(CoursePage):
"""
Creates new group configuration.
"""
self.q(css=".new-button").first.click()
self.q(css=".experiment-groups .new-button").first.click()
@property
def no_group_configuration_message_is_present(self):
return self.q(css='.wrapper-content .no-group-configurations-content').present
return self.q(css='.wrapper-content .experiment-groups .no-group-configurations-content').present
@property
def no_group_configuration_message_text(self):
return self.q(css='.wrapper-content .no-group-configurations-content').text[0]
return self.q(css='.wrapper-content .experiment-groups .no-group-configurations-content').text[0]
class GroupConfiguration(object):
......
......@@ -449,7 +449,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
config.add_group() # Group C
# Save the configuration
self.assertEqual(config.get_text('.action-primary'), "CREATE")
self.assertEqual(config.get_text('.action-primary'), "Create")
self.assertTrue(config.delete_button_is_absent)
config.save()
......@@ -466,7 +466,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.assertTrue(config.id)
config.name = "Second Group Configuration Name"
config.description = "Second Description of the group configuration."
self.assertEqual(config.get_text('.action-primary'), "SAVE")
self.assertEqual(config.get_text('.action-primary'), "Save")
# Add new group
config.add_group() # Group D
# Remove group with name "New Group Name"
......
......@@ -377,4 +377,3 @@ class TestMasqueradedGroup(StaffMasqueradeTestCase):
self._verify_masquerade_for_group(self.user_partition.groups[0])
self._verify_masquerade_for_group(self.user_partition.groups[1])
self._verify_masquerade_for_group(None)
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