Commit dfaae534 by Muzaffar yousaf

Merge pull request #6707 from edx/muzaffar/content-groups-tnl1185

Content Groups Usage and Delete functionality.
parents 985a492e a31e3841
......@@ -14,6 +14,7 @@ define([
experimentGroupConfigurations.url = groupConfigurationUrl;
experimentGroupConfigurations.outlineUrl = courseOutlineUrl;
contentGroupConfiguration.urlRoot = groupConfigurationUrl;
contentGroupConfiguration.outlineUrl = courseOutlineUrl;
new GroupConfigurationsPage({
el: $('#content'),
experimentsEnabled: experimentsEnabled,
......
......@@ -8,8 +8,17 @@ define([
return {
name: '',
version: 1,
order: null
};
order: null,
usage: []
};
},
url : function() {
var parentModel = this.collection.parents[0];
return parentModel.urlRoot + '/' + encodeURIComponent(parentModel.id) + '/' + encodeURIComponent(this.id);
},
reset: function() {
this.set(this._originalAttributes, { parse: true });
},
isEmpty: function() {
......@@ -20,7 +29,8 @@ define([
return {
id: this.get('id'),
name: this.get('name'),
version: this.get('version')
version: this.get('version'),
usage: this.get('usage')
};
},
......
......@@ -106,10 +106,12 @@ define([
'groups': [
{
'version': 1,
'name': 'Group 1'
'name': 'Group 1',
'usage': []
}, {
'version': 1,
'name': 'Group 2'
'name': 'Group 2',
'usage': []
}
]
},
......@@ -125,11 +127,13 @@ define([
{
'version': 1,
'order': 0,
'name': 'Group 1'
'name': 'Group 1',
'usage': []
}, {
'version': 1,
'order': 1,
'name': 'Group 2'
'name': 'Group 2',
'usage': []
}
],
'usage': []
......
......@@ -108,7 +108,7 @@ define([
});
it('should show a notification message if a content group is changed', function () {
this.view.contentGroupConfiguration.get('groups').add({name: 'Content Group'});
this.view.contentGroupConfiguration.get('groups').add({id: 0, name: 'Content Group'});
expect(this.view.onBeforeUnload())
.toBe('You have unsaved changes. Do you really want to leave this page?');
});
......
......@@ -3,16 +3,26 @@
* It is expected to be backed by a Group model.
*/
define([
'js/views/baseview'
], function(BaseView) {
'js/views/baseview', 'underscore', 'gettext', 'underscore.string'
], function(BaseView, _, gettext, str) {
'use strict';
var ContentGroupDetailsView = BaseView.extend({
tagName: 'div',
className: 'content-group-details collection',
events: {
'click .edit': 'editGroup'
'click .edit': 'editGroup',
'click .show-groups': 'showContentGroupUsages',
'click .hide-groups': 'hideContentGroupUsages'
},
className: function () {
var index = this.model.collection.indexOf(this.model);
return [
'collection',
'content-group-details',
'content-group-details-' + index
].join(' ');
},
editGroup: function() {
......@@ -21,10 +31,66 @@ define([
initialize: function() {
this.template = this.loadTemplate('content-group-details');
this.listenTo(this.model, 'change', this.render);
},
render: function(showContentGroupUsages) {
var attrs = $.extend({}, this.model.attributes, {
usageCountMessage: this.getUsageCountTitle(),
outlineAnchorMessage: this.getOutlineAnchorMessage(),
index: this.model.collection.indexOf(this.model),
showContentGroupUsages: showContentGroupUsages || false
});
this.$el.html(this.template(attrs));
return this;
},
showContentGroupUsages: function(event) {
if (event && event.preventDefault) { event.preventDefault(); }
this.render(true);
},
render: function() {
this.$el.html(this.template(this.model.toJSON()));
hideContentGroupUsages: function(event) {
if (event && event.preventDefault) { event.preventDefault(); }
this.render(false);
},
getUsageCountTitle: function () {
var count = this.model.get('usage').length, message;
if (count === 0) {
message = gettext('Not in Use');
} else {
message = ngettext(
/*
Translators: 'count' is number of units that the group
configuration is used in.
*/
'Used in %(count)s unit', 'Used in %(count)s units',
count
);
}
return interpolate(message, { count: count }, true);
},
getOutlineAnchorMessage: function () {
var message = gettext(
/*
Translators: 'outlineAnchor' is an anchor pointing to
the course outline page.
*/
'This content group is not in use. Add a content group to any unit from the %(outlineAnchor)s.'
),
anchor = str.sprintf(
'<a href="%(url)s" title="%(text)s">%(text)s</a>',
{
url: this.model.collection.parents[0].outlineUrl,
text: gettext('Course Outline')
}
);
return str.sprintf(message, {outlineAnchor: anchor});
}
});
......
......@@ -23,9 +23,11 @@ function(ListItemEditorView, _) {
getTemplateOptions: function() {
return {
id: this.model.escape('id'),
name: this.model.escape('name'),
index: this.model.collection.indexOf(this.model),
isNew: this.model.isNew(),
usage: this.model.get('usage'),
uniqueId: _.uniqueId()
};
},
......
......@@ -5,15 +5,30 @@
* 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) {
'js/views/list_item', 'js/views/content_group_editor', 'js/views/content_group_details', 'gettext', "js/views/utils/view_utils"
], function(ListItemView, ContentGroupEditorView, ContentGroupDetailsView, gettext) {
'use strict';
var ContentGroupItemView = ListItemView.extend({
events: {
'click .delete': 'deleteItem'
},
tagName: 'section',
baseClassName: 'content-group',
canDelete: true,
itemDisplayName: gettext('content group'),
attributes: function () {
return {
'id': this.model.get('id'),
'tabindex': -1
};
},
createEditView: function() {
return new ContentGroupEditorView({model: this.model});
},
......
......@@ -100,6 +100,28 @@
color: $gray-l1;
margin-left: $baseline;
&.group-configuration-info-inline {
display: table;
width: 70%;
margin: ($baseline/4) 0 ($baseline/2) $baseline;
li {
@include box-sizing(border-box);
display: table-cell;
margin-right: 1%;
&.group-configuration-usage-count {
font-style: italic;
}
}
}
&.group-configuration-info-block {
li {
padding: ($baseline/4) 0;
}
}
&.collection-info-inline {
display: table;
width: 70%;
......@@ -355,12 +377,31 @@
}
}
.field.add-collection-name label {
@extend %t-title5;
display: inline-block;
vertical-align: bottom;
.field.add-collection-name {
label {
width: 50%;
@extend %t-title5;
display: inline-block;
vertical-align: bottom;
}
.group-configuration-id {
display: inline-block;
width: 45%;
text-align: right;
vertical-align: top;
color: $gray-l1;
.group-configuration-value {
@extend %t-strong;
white-space: nowrap;
margin-left: ($baseline*0.5);
}
}
}
.actions {
box-shadow: inset 0 1px 2px $shadow;
border-top: 1px solid $gray-l1;
......@@ -443,7 +484,7 @@
.collection-header{
.title {
margin-bottom: 0;
margin-bottom: 0;
}
}
}
......@@ -457,28 +498,6 @@
color: $gray-l1;
margin-left: $baseline;
&.group-configuration-info-inline {
display: table;
width: 70%;
margin: ($baseline/4) 0 ($baseline/2) $baseline;
li {
@include box-sizing(border-box);
display: table-cell;
margin-right: 1%;
&.group-configuration-usage-count {
font-style: italic;
}
}
}
&.group-configuration-info-block {
li {
padding: ($baseline/4) 0;
}
}
.group-configuration-label {
text-transform: uppercase;
}
......@@ -526,27 +545,12 @@
.group-configuration-edit {
.add-collection-name label {
width: 50%;
padding-right: 5%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
}
.group-configuration-id {
display: inline-block;
width: 45%;
text-align: right;
vertical-align: top;
color: $gray-l1;
.group-configuration-value {
@extend %t-strong;
white-space: nowrap;
margin-left: ($baseline*0.5);
}
}
.field-group {
@include clearfix();
margin: 0 0 ($baseline/2) 0;
......
......@@ -67,7 +67,8 @@
<div class="content-groups-doc">
<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>${_("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}.").format(em_start="<strong>", em_end="</strong>")}</p>
<p>${_("You can delete a content group only if it is not in use by a unit. To delete a content group, hover over its box and click the delete icon.")}</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>
......
<div class="collection-details">
<div class="collection-details wrapper-group-configuration">
<header class="collection-header">
<h3 class="title">
<%- name %>
<a href="#" class="toggle group-toggle <% if (showContentGroupUsages){ print('hide'); } else { print('show'); } %>-groups">
<i class="ui-toggle-expansion icon fa fa-caret-<% if (showContentGroupUsages){ print('down'); } else { print('right'); } %>"></i>
<%= name %>
</a>
</h3>
</header>
<ul class="actions">
<ol class="collection-info group-configuration-info group-configuration-info-<% if(showContentGroupUsages){ 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 (!showContentGroupUsages) { %>
<li class="group-configuration-usage-count">
<%= usageCountMessage %>
</li>
<% } %>
</ol>
<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>
<% if (_.isEmpty(usage)) { %>
<li class="action action-delete wrapper-delete-button" data-tooltip="<%= gettext('Delete') %>">
<button class="delete action-icon"><i class="icon fa fa-trash-o"></i><span><%= gettext("Delete") %></span></button>
</li>
<% } else { %>
<li class="action action-delete wrapper-delete-button" data-tooltip="<%= gettext('Cannot delete when in use by a unit') %>">
<button class="delete action-icon is-disabled" aria-disabled="true"><i class="icon fa fa-trash-o"></i><span><%= gettext("Delete") %></span></button>
</li>
<% } %>
</ul>
</div>
<% if (showContentGroupUsages) { %>
<div class="collection-references wrapper-group-configuration-usages">
<% if (!_.isEmpty(usage)) { %>
<h4 class="intro group-configuration-usage-text"><%= gettext('This content group is used in:') %></h4>
<ol class="usage group-configuration-usage">
<% _.each(usage, function(unit) { %>
<li class="usage-unit group-configuration-usage-unit">
<p><a href=<%= unit.url %> ><%= unit.label %></a></p>
</li>
<% }) %>
</ol>
<% } else { %>
<p class="group-configuration-usage-text">
<%= outlineAnchorMessage %>
</p>
<% } %>
</div>
<% } %>
......@@ -7,13 +7,40 @@
<div class="wrapper-form">
<fieldset class="collection-fields">
<div class="input-wrap field text required add-collection-name <% if(error && error.attributes && error.attributes.name) { print('error'); } %>">
<label for="group-cohort-name-<%= uniqueId %>"><%= gettext("Content Group Name") %></label>
<label for="group-cohort-name-<%= uniqueId %>"><%= gettext("Content Group Name") %></label><%
if (!_.isUndefined(id) && !_.isEmpty(id)) {
%><span class="group-configuration-id">
<span class="group-configuration-label"><%= gettext('Content Group ID') %></span>
<span class="group-configuration-value"><%= id %></span>
</span><%
}
%>
<input name="group-cohort-name" id="group-cohort-name-<%= uniqueId %>" class="collection-name-input input-text" value="<%- name %>" type="text" placeholder="<%= gettext("This is the name of the group") %>">
</div>
</fieldset>
<% if (!_.isEmpty(usage)) { %>
<div class="wrapper-group-configuration-validation usage-validation">
<i class="icon fa fa-warning"></i>
<p class="group-configuration-validation-text">
<%= gettext('This content group is used in one or more units.') %>
</p>
</div>
<% } %>
</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>
<% if (!isNew) { %>
<% if (_.isEmpty(usage)) { %>
<span class="wrapper-delete-button" data-tooltip="<%= gettext("Delete") %>">
<a class="button action-delete delete" href="#"><%= gettext("Delete") %></a>
</span>
<% } else { %>
<span class="wrapper-delete-button" data-tooltip="<%= gettext('Cannot delete when in use by a unit') %>">
<a class="button action-delete delete is-disabled" href="#" aria-disabled="true" ><%= gettext("Delete") %></a>
</span>
<% } %>
<% } %>
</div>
</form>
......@@ -108,8 +108,8 @@ urlpatterns += patterns(
url(r'^videos/{}$'.format(settings.COURSE_KEY_PATTERN), 'videos_handler'),
url(r'^video_encodings_download/{}$'.format(settings.COURSE_KEY_PATTERN), 'video_encodings_download'),
url(r'^group_configurations/{}$'.format(settings.COURSE_KEY_PATTERN), 'group_configurations_list_handler'),
url(r'^group_configurations/{}/(?P<group_configuration_id>\d+)/?$'.format(settings.COURSE_KEY_PATTERN),
'group_configurations_detail_handler'),
url(r'^group_configurations/{}/(?P<group_configuration_id>\d+)(/)?(?P<group_id>\d+)?$'.format(
settings.COURSE_KEY_PATTERN), 'group_configurations_detail_handler'),
url(r'^api/val/v0/', include('edxval.urls')),
)
......
......@@ -777,10 +777,8 @@ class ModuleStoreRead(ModuleStoreAssetBase):
for key, criteria in qualifiers.iteritems():
is_set, value = _is_set_on(key)
if isinstance(criteria, dict) and '$exists' in criteria and criteria['$exists'] == is_set:
continue
if not is_set:
return False
if not self._value_matches(value, criteria):
......
......@@ -167,8 +167,11 @@ class GroupConfiguration(object):
return self.find_css('.actions .delete.is-disabled').present
@property
def delete_button_is_absent(self):
return not self.find_css('.actions .delete').present
def delete_button_is_present(self):
"""
Returns whether or not the delete icon is present.
"""
return self.find_css('.actions .delete').present
def delete(self):
"""
......
......@@ -5,9 +5,15 @@ Acceptance tests for Studio's Setting pages
from nose.plugins.attrib import attr
from base_studio_test import StudioCourseTest
from bok_choy.promise import EmptyPromise
from ...fixtures.course import XBlockFixtureDesc
from ..helpers import create_user_partition_json
from ...pages.studio.overview import CourseOutlinePage
from ...pages.studio.settings_advanced import AdvancedSettingsPage
from ...pages.studio.settings_group_configurations import GroupConfigurationsPage
from unittest import skip
from textwrap import dedent
from xmodule.partitions.partitions import Group
@attr('shard_1')
......@@ -25,6 +31,26 @@ class ContentGroupConfigurationTest(StudioCourseTest):
self.course_info['run']
)
self.outline_page = CourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
def populate_course_fixture(self, course_fixture):
"""
Populates test course with chapter, sequential, and 1 problems.
The problem is visible only to Group "alpha".
"""
course_fixture.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit')
)
)
)
def create_and_verify_content_group(self, name, existing_groups):
"""
Creates a new content group and verifies that it was properly created.
......@@ -38,7 +64,7 @@ class ContentGroupConfigurationTest(StudioCourseTest):
config.name = name
# Save the content group
self.assertEqual(config.get_text('.action-primary'), "Create")
self.assertTrue(config.delete_button_is_absent)
self.assertFalse(config.delete_button_is_present)
config.save()
self.assertIn(name, config.name)
return config
......@@ -84,16 +110,68 @@ class ContentGroupConfigurationTest(StudioCourseTest):
self.assertIn("Updated Second Content Group", second_config.name)
def test_cannot_delete_content_group(self):
def test_cannot_delete_used_content_group(self):
"""
Scenario: Delete is not currently supported for content groups.
Given I have a course without content groups
When I create a content group
Then there is no delete button
Scenario: Ensure that the user cannot delete used content group.
Given I have a course with 1 Content Group
And I go to the Group Configuration page
When I try to delete the Content Group with name "New Content Group"
Then I see the delete button is disabled.
"""
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
create_user_partition_json(
0,
'Configuration alpha,',
'Content Group Partition',
[Group("0", 'alpha')],
scheme="cohort"
)
],
},
})
problem_data = dedent("""
<problem markdown="Simple Problem" max_attempts="" weight="">
<p>Choose Yes.</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<choice correct="true">Yes</choice>
</checkboxgroup>
</choiceresponse>
</problem>
""")
vertical = self.course_fixture.get_nested_xblocks(category="vertical")[0]
self.course_fixture.create_xblock(
vertical.locator,
XBlockFixtureDesc('problem', "VISIBLE TO ALPHA", data=problem_data, metadata={"group_access": {0: [0]}}),
)
self.group_configurations_page.visit()
config = self.group_configurations_page.content_groups[0]
self.assertTrue(config.delete_button_is_disabled)
def test_can_delete_unused_content_group(self):
"""
Scenario: Ensure that the user can delete unused content group.
Given I have a course with 1 Content Group
And I go to the Group Configuration page
When I delete the Content Group with name "New Content Group"
Then I see that there is no Content Group
When I refresh the page
Then I see that the content group has been deleted
"""
self.group_configurations_page.visit()
config = self.create_and_verify_content_group("New Content Group", 0)
self.assertTrue(config.delete_button_is_absent)
self.assertTrue(config.delete_button_is_present)
self.assertEqual(len(self.group_configurations_page.content_groups), 1)
# Delete content group
config.delete()
self.assertEqual(len(self.group_configurations_page.content_groups), 0)
self.group_configurations_page.visit()
self.assertEqual(len(self.group_configurations_page.content_groups), 0)
def test_must_supply_name(self):
"""
......@@ -129,6 +207,26 @@ class ContentGroupConfigurationTest(StudioCourseTest):
config.cancel()
self.assertEqual(0, len(self.group_configurations_page.content_groups))
def test_content_group_empty_usage(self):
"""
Scenario: When content group is not used, ensure that the link to outline page works correctly.
Given I have a course without content group
And I create new content group
Then I see a link to the outline page
When I click on the outline link
Then I see the outline page
"""
self.group_configurations_page.visit()
config = self.create_and_verify_content_group("New Content Group", 0)
config.toggle()
config.click_outline_anchor()
# Waiting for the page load and verify that we've landed on course outline page
EmptyPromise(
lambda: self.outline_page.is_browser_on_page(), "loaded page {!r}".format(self.outline_page),
timeout=30
).fulfill()
@attr('shard_1')
class AdvancedSettingsValidationTest(StudioCourseTest):
......
......@@ -449,7 +449,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
# Save the configuration
self.assertEqual(config.get_text('.action-primary'), "Create")
self.assertTrue(config.delete_button_is_absent)
self.assertFalse(config.delete_button_is_present)
config.save()
self._assert_fields(
......
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