Commit d447c075 by cahrens

Support staff locking on the unit page

STUD-1873
parent cf70eb6e
...@@ -231,7 +231,7 @@ class XBlockVisibilityTestCase(TestCase): ...@@ -231,7 +231,7 @@ class XBlockVisibilityTestCase(TestCase):
vertical.start = self.future vertical.start = self.future
modulestore().update_item(vertical, self.dummy_user) modulestore().update_item(vertical, self.dummy_user)
self.assertTrue(utils.is_xblock_visible_to_students(vertical)) self.assertTrue(utils.is_currently_visible_to_students(vertical))
def _test_visible_to_students(self, expected_visible_without_lock, name, start_date, publish=False): def _test_visible_to_students(self, expected_visible_without_lock, name, start_date, publish=False):
""" """
...@@ -239,13 +239,13 @@ class XBlockVisibilityTestCase(TestCase): ...@@ -239,13 +239,13 @@ class XBlockVisibilityTestCase(TestCase):
with and without visible_to_staff_only set. with and without visible_to_staff_only set.
""" """
no_staff_lock = self._create_xblock_with_start_date(name, start_date, publish, visible_to_staff_only=False) no_staff_lock = self._create_xblock_with_start_date(name, start_date, publish, visible_to_staff_only=False)
self.assertEqual(expected_visible_without_lock, utils.is_xblock_visible_to_students(no_staff_lock)) self.assertEqual(expected_visible_without_lock, utils.is_currently_visible_to_students(no_staff_lock))
# any xblock with visible_to_staff_only set to True should not be visible to students. # any xblock with visible_to_staff_only set to True should not be visible to students.
staff_lock = self._create_xblock_with_start_date( staff_lock = self._create_xblock_with_start_date(
name + "_locked", start_date, publish, visible_to_staff_only=True name + "_locked", start_date, publish, visible_to_staff_only=True
) )
self.assertFalse(utils.is_xblock_visible_to_students(staff_lock)) self.assertFalse(utils.is_currently_visible_to_students(staff_lock))
def _create_xblock_with_start_date(self, name, start_date, publish=False, visible_to_staff_only=False): def _create_xblock_with_start_date(self, name, start_date, publish=False, visible_to_staff_only=False):
"""Helper to create an xblock with a start date, optionally publishing it""" """Helper to create an xblock with a start date, optionally publishing it"""
......
...@@ -164,9 +164,10 @@ def compute_publish_state(xblock): ...@@ -164,9 +164,10 @@ def compute_publish_state(xblock):
return modulestore().compute_publish_state(xblock) return modulestore().compute_publish_state(xblock)
def is_xblock_visible_to_students(xblock): def is_currently_visible_to_students(xblock):
""" """
Returns true if there is a published version of the xblock that has been released. Returns true if there is a published version of the xblock that is currently visible to students.
This means that it has a release date in the past, and the xblock has not been set to staff only.
""" """
try: try:
......
...@@ -21,7 +21,7 @@ from xblock.fields import Scope ...@@ -21,7 +21,7 @@ from xblock.fields import Scope
from xblock.plugin import PluginMissingError from xblock.plugin import PluginMissingError
from xblock.runtime import Mixologist from xblock.runtime import Mixologist
from contentstore.utils import get_lms_link_for_item, compute_publish_state, is_xblock_visible_to_students from contentstore.utils import get_lms_link_for_item, compute_publish_state
from contentstore.views.helpers import get_parent_xblock, is_unit, xblock_type_display_name from contentstore.views.helpers import get_parent_xblock, is_unit, xblock_type_display_name
from contentstore.views.item import create_xblock_info from contentstore.views.item import create_xblock_info
...@@ -207,7 +207,6 @@ def container_handler(request, usage_key_string): ...@@ -207,7 +207,6 @@ def container_handler(request, usage_key_string):
'xblock_locator': xblock.location, 'xblock_locator': xblock.location,
'unit': unit, 'unit': unit,
'is_unit_page': is_unit_page, 'is_unit_page': is_unit_page,
'is_visible_to_students': is_xblock_visible_to_students(xblock),
'subsection': subsection, 'subsection': subsection,
'section': section, 'section': section,
'new_unit_category': 'vertical', 'new_unit_category': 'vertical',
......
...@@ -37,6 +37,7 @@ from util.date_utils import get_default_time_display ...@@ -37,6 +37,7 @@ from util.date_utils import get_default_time_display
from util.json_request import expect_json, JsonResponse from util.json_request import expect_json, JsonResponse
from .access import has_course_access from .access import has_course_access
from contentstore.utils import is_currently_visible_to_students
from contentstore.views.helpers import is_unit, xblock_studio_url, xblock_primary_child_category, \ from contentstore.views.helpers import is_unit, xblock_studio_url, xblock_primary_child_category, \
xblock_type_display_name, get_parent_xblock xblock_type_display_name, get_parent_xblock
from contentstore.views.preview import get_preview_fragment from contentstore.views.preview import get_preview_fragment
...@@ -104,10 +105,12 @@ def xblock_handler(request, usage_key_string): ...@@ -104,10 +105,12 @@ def xblock_handler(request, usage_key_string):
to None! Absent ones will be left alone. to None! Absent ones will be left alone.
:nullout: which metadata fields to set to None :nullout: which metadata fields to set to None
:graderType: change how this unit is graded :graderType: change how this unit is graded
:publish: can be either -- 'make_public' (which publishes the content) or 'discard_changes' :publish: can be:
(which reverts to the last published version). If 'discard_changes', the other fields 'make_public': publish the content
will not be used; that is, it is not possible to update and discard changes 'republish': publish this item *only* if it was previously published
in a single operation. 'discard_changes' - reverts to the last published version
Note: If 'discard_changes', the other fields will not be used; that is, it is not possible
to update and discard changes in a single operation.
The JSON representation on the updated xblock (minus children) is returned. The JSON representation on the updated xblock (minus children) is returned.
if usage_key_string is not specified, create a new xblock instance, either by duplicating if usage_key_string is not specified, create a new xblock instance, either by duplicating
...@@ -136,7 +139,7 @@ def xblock_handler(request, usage_key_string): ...@@ -136,7 +139,7 @@ def xblock_handler(request, usage_key_string):
# right now can't combine output of this w/ output of _get_module_info, but worthy goal # right now can't combine output of this w/ output of _get_module_info, but worthy goal
return JsonResponse(CourseGradingModel.get_section_grader_type(usage_key)) return JsonResponse(CourseGradingModel.get_section_grader_type(usage_key))
# TODO: pass fields to _get_module_info and only return those # TODO: pass fields to _get_module_info and only return those
rsp = _get_module_info(usage_key, request.user) rsp = _get_module_info(_get_xblock(usage_key, request.user))
return JsonResponse(rsp) return JsonResponse(rsp)
else: else:
return HttpResponse(status=406) return HttpResponse(status=406)
...@@ -145,9 +148,9 @@ def xblock_handler(request, usage_key_string): ...@@ -145,9 +148,9 @@ def xblock_handler(request, usage_key_string):
_delete_item(usage_key, request.user) _delete_item(usage_key, request.user)
return JsonResponse() return JsonResponse()
else: # Since we have a usage_key, we are updating an existing xblock. else: # Since we have a usage_key, we are updating an existing xblock.
return _save_item( return _save_xblock(
request.user, request.user,
usage_key, _get_xblock(usage_key, request.user),
data=request.json.get('data'), data=request.json.get('data'),
children=request.json.get('children'), children=request.json.get('children'),
metadata=request.json.get('metadata'), metadata=request.json.get('metadata'),
...@@ -289,7 +292,7 @@ def xblock_outline_handler(request, usage_key_string): ...@@ -289,7 +292,7 @@ def xblock_outline_handler(request, usage_key_string):
return Http404 return Http404
def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout=None, def _save_xblock(user, xblock, data=None, children=None, metadata=None, nullout=None,
grader_type=None, publish=None): grader_type=None, publish=None):
""" """
Saves xblock w/ its fields. Has special processing for grader_type, publish, and nullout and Nones in metadata. Saves xblock w/ its fields. Has special processing for grader_type, publish, and nullout and Nones in metadata.
...@@ -298,32 +301,19 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout ...@@ -298,32 +301,19 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout
""" """
store = modulestore() store = modulestore()
try:
existing_item = store.get_item(usage_key)
except ItemNotFoundError:
if usage_key.category in CREATE_IF_NOT_FOUND:
# New module at this location, for pages that are not pre-created.
# Used for course info handouts.
existing_item = store.create_item(user.id, usage_key.course_key, usage_key.block_type, usage_key.block_id)
else:
raise
except InvalidLocationError:
log.error("Can't find item by location.")
return JsonResponse({"error": "Can't find item by location: " + unicode(usage_key)}, 404)
# Don't allow updating an xblock and discarding changes in a single operation (unsupported by UI). # Don't allow updating an xblock and discarding changes in a single operation (unsupported by UI).
if publish == "discard_changes": if publish == "discard_changes":
store.revert_to_published(usage_key, user.id) store.revert_to_published(xblock.location, user.id)
# Returning the same sort of result that we do for other save operations. In the future, # Returning the same sort of result that we do for other save operations. In the future,
# we may want to return the full XBlockInfo. # we may want to return the full XBlockInfo.
return JsonResponse({'id': unicode(usage_key)}) return JsonResponse({'id': unicode(xblock.location)})
old_metadata = own_metadata(existing_item) old_metadata = own_metadata(xblock)
old_content = existing_item.get_explicitly_set_fields_by_scope(Scope.content) old_content = xblock.get_explicitly_set_fields_by_scope(Scope.content)
if data: if data:
# TODO Allow any scope.content fields not just "data" (exactly like the get below this) # TODO Allow any scope.content fields not just "data" (exactly like the get below this)
existing_item.data = data xblock.data = data
else: else:
data = old_content['data'] if 'data' in old_content else None data = old_content['data'] if 'data' in old_content else None
...@@ -332,7 +322,7 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout ...@@ -332,7 +322,7 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout
for child in children: for child in children:
child_usage_key = usage_key_with_run(child) child_usage_key = usage_key_with_run(child)
children_usage_keys.append(child_usage_key) children_usage_keys.append(child_usage_key)
existing_item.children = children_usage_keys xblock.children = children_usage_keys
# also commit any metadata which might have been passed along # also commit any metadata which might have been passed along
if nullout is not None or metadata is not None: if nullout is not None or metadata is not None:
...@@ -341,53 +331,61 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout ...@@ -341,53 +331,61 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout
# 'apply' the submitted metadata, so we don't end up deleting system metadata. # 'apply' the submitted metadata, so we don't end up deleting system metadata.
if nullout is not None: if nullout is not None:
for metadata_key in nullout: for metadata_key in nullout:
setattr(existing_item, metadata_key, None) setattr(xblock, metadata_key, None)
# update existing metadata with submitted metadata (which can be partial) # update existing metadata with submitted metadata (which can be partial)
# IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If # IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If
# the intent is to make it None, use the nullout field # the intent is to make it None, use the nullout field
if metadata is not None: if metadata is not None:
for metadata_key, value in metadata.items(): for metadata_key, value in metadata.items():
field = existing_item.fields[metadata_key] field = xblock.fields[metadata_key]
if value is None: if value is None:
field.delete_from(existing_item) field.delete_from(xblock)
else: else:
try: try:
value = field.from_json(value) value = field.from_json(value)
except ValueError: except ValueError:
return JsonResponse({"error": "Invalid data"}, 400) return JsonResponse({"error": "Invalid data"}, 400)
field.write_to(existing_item, value) field.write_to(xblock, value)
if callable(getattr(existing_item, "editor_saved", None)): if callable(getattr(xblock, "editor_saved", None)):
existing_item.editor_saved(user, old_metadata, old_content) xblock.editor_saved(user, old_metadata, old_content)
# commit to datastore # commit to datastore
store.update_item(existing_item, user.id) store.update_item(xblock, user.id)
# for static tabs, their containing course also records their display name # for static tabs, their containing course also records their display name
if usage_key.category == 'static_tab': if xblock.location.category == 'static_tab':
course = store.get_course(usage_key.course_key) course = store.get_course(xblock.location.course_key)
# find the course's reference to this tab and update the name. # find the course's reference to this tab and update the name.
static_tab = CourseTabList.get_tab_by_slug(course.tabs, usage_key.name) static_tab = CourseTabList.get_tab_by_slug(course.tabs, xblock.location.name)
# only update if changed # only update if changed
if static_tab and static_tab['name'] != existing_item.display_name: if static_tab and static_tab['name'] != xblock.display_name:
static_tab['name'] = existing_item.display_name static_tab['name'] = xblock.display_name
store.update_item(course, user.id) store.update_item(course, user.id)
result = { result = {
'id': unicode(usage_key), 'id': unicode(xblock.location),
'data': data, 'data': data,
'metadata': own_metadata(existing_item) 'metadata': own_metadata(xblock)
} }
if grader_type is not None: if grader_type is not None:
result.update(CourseGradingModel.update_section_grader_type(existing_item, grader_type, user)) result.update(CourseGradingModel.update_section_grader_type(xblock, grader_type, user))
# If publish is set to 'republish' and this item has previously been published, then this
# new item should be republished. This is used by staff locking to ensure that changing the draft
# value of the staff lock will also update the published version.
if publish == 'republish':
published = modulestore().has_item(xblock.location, revision=ModuleStoreEnum.RevisionOption.published_only)
if published:
publish = 'make_public'
# Make public after updating the xblock, in case the caller asked for both an update and a publish. # Make public after updating the xblock, in case the caller asked for both an update and a publish.
# Although not supported in the UI, Bok Choy tests use this. # Used by Bok Choy tests and by republishing of staff locks.
if publish == 'make_public': if publish == 'make_public':
modulestore().publish(existing_item.location, user.id) modulestore().publish(xblock.location, user.id)
# Note that children aren't being returned until we have a use case. # Note that children aren't being returned until we have a use case.
return JsonResponse(result) return JsonResponse(result)
...@@ -552,31 +550,40 @@ def orphan_handler(request, course_key_string): ...@@ -552,31 +550,40 @@ def orphan_handler(request, course_key_string):
raise PermissionDenied() raise PermissionDenied()
def _get_module_info(usage_key, user, rewrite_static_links=True): def _get_xblock(usage_key, user):
""" """
metadata, data, id representation of a leaf module fetcher. Returns the xblock for the specified usage key. Note: if failing to find a key with a category
:param usage_key: A UsageKey in the CREATE_IF_NOT_FOUND list, an xblock will be created and saved automatically.
""" """
store = modulestore() store = modulestore()
try: try:
module = store.get_item(usage_key) return store.get_item(usage_key)
except ItemNotFoundError: except ItemNotFoundError:
if usage_key.category in CREATE_IF_NOT_FOUND: if usage_key.category in CREATE_IF_NOT_FOUND:
# Create a new one for certain categories only. Used for course info handouts. # Create a new one for certain categories only. Used for course info handouts.
module = store.create_item(user.id, usage_key.course_key, usage_key.block_type, block_id=usage_key.block_id) return store.create_item(user.id, usage_key.course_key, usage_key.block_type, block_id=usage_key.block_id)
else: else:
raise raise
except InvalidLocationError:
log.error("Can't find item by location.")
return JsonResponse({"error": "Can't find item by location: " + unicode(usage_key)}, 404)
data = getattr(module, 'data', '') def _get_module_info(xblock, rewrite_static_links=True):
"""
metadata, data, id representation of a leaf module fetcher.
:param usage_key: A UsageKey
"""
data = getattr(xblock, 'data', '')
if rewrite_static_links: if rewrite_static_links:
data = replace_static_urls( data = replace_static_urls(
data, data,
None, None,
course_id=module.location.course_key course_id=xblock.location.course_key
) )
# Note that children aren't being returned until we have a use case. # Note that children aren't being returned until we have a use case.
return create_xblock_info(module, data=data, metadata=own_metadata(module), include_ancestor_info=True) return create_xblock_info(xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=True)
def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False, def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False,
...@@ -630,6 +637,8 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F ...@@ -630,6 +637,8 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
"released_to_students": datetime.now(UTC) > xblock.start, "released_to_students": datetime.now(UTC) > xblock.start,
"release_date": release_date, "release_date": release_date,
"release_date_from": _get_release_date_from(xblock) if release_date else None, "release_date_from": _get_release_date_from(xblock) if release_date else None,
"visible_to_staff_only": xblock.visible_to_staff_only,
"currently_visible_to_students": is_currently_visible_to_students(xblock),
} }
if data is not None: if data is not None:
xblock_info["data"] = data xblock_info["data"] = data
......
...@@ -157,39 +157,3 @@ class ContainerPageTestCase(StudioPageTestCase): ...@@ -157,39 +157,3 @@ class ContainerPageTestCase(StudioPageTestCase):
""" """
empty_child_container = self._create_item(self.vertical.location, 'split_test', 'Split Test') empty_child_container = self._create_item(self.vertical.location, 'split_test', 'Split Test')
self.validate_preview_html(empty_child_container, self.reorderable_child_view, can_add=False) self.validate_preview_html(empty_child_container, self.reorderable_child_view, can_add=False)
def test_unreleased_private_container_messages(self):
"""
Verify that an unreleased private container does not display messages.
"""
self.validate_html_for_messages(self.unreleased_private_vertical, False)
def test_unreleased_public_container_messages(self):
"""
Verify that an unreleased public container does not display messages.
"""
self.validate_html_for_messages(self.unreleased_public_vertical, False)
def test_released_private_container_message(self):
"""
Verify that a released private container does not display messages.
"""
self.validate_html_for_messages(self.released_private_vertical, False)
def test_released_public_container_message(self):
"""
Verify that a released public container does display messages.
"""
self.validate_html_for_messages(self.released_public_vertical, True)
def validate_html_for_messages(self, xblock, has_messages):
"""
Validate that the specified HTML has the appropriate messages for the current student visibility state.
"""
# Verify that there are no warning messages for blocks that are not visible to students
html = self.get_page_html(xblock)
messages_html = '<div class="container-message wrapper-message">'
if has_messages:
self.assertIn(messages_html, html)
else:
self.assertNotIn(messages_html, html)
...@@ -567,6 +567,55 @@ class TestEditItem(ItemTest): ...@@ -567,6 +567,55 @@ class TestEditItem(ItemTest):
published = self.verify_publish_state(self.problem_usage_key, PublishState.public) published = self.verify_publish_state(self.problem_usage_key, PublishState.public)
self.assertIsNone(published.due) self.assertIsNone(published.due)
def test_republish(self):
""" Test republishing an item. """
new_display_name = 'New Display Name'
republish_data = {
'publish': 'republish',
'display_name': new_display_name
}
# When the problem is first created, it is only in draft (because of its category).
self.verify_publish_state(self.problem_usage_key, PublishState.private)
# Republishing when only in draft will update the draft but not cause a public item to be created.
self.client.ajax_post(
self.problem_update_url,
data={
'publish': 'republish',
'metadata': {
'display_name': new_display_name
}
}
)
self.verify_publish_state(self.problem_usage_key, PublishState.private)
draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True)
self.assertEqual(draft.display_name, new_display_name)
# Publish the item
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
# Now republishing should update the published version
new_display_name_2 = 'New Display Name 2'
self.client.ajax_post(
self.problem_update_url,
data={
'publish': 'republish',
'metadata': {
'display_name': new_display_name_2
}
}
)
self.verify_publish_state(self.problem_usage_key, PublishState.public)
published = modulestore().get_item(
self.problem_usage_key,
revision=ModuleStoreEnum.RevisionOption.published_only
)
self.assertEqual(published.display_name, new_display_name_2)
def _make_draft_content_different_from_published(self): def _make_draft_content_different_from_published(self):
""" """
Helper method to create different draft and published versions of a problem. Helper method to create different draft and published versions of a problem.
......
...@@ -38,7 +38,7 @@ define(["backbone", "underscore", "js/utils/module"], function(Backbone, _, Modu ...@@ -38,7 +38,7 @@ define(["backbone", "underscore", "js/utils/module"], function(Backbone, _, Modu
* If true, only course staff can see the xblock regardless of publish status or * If true, only course staff can see the xblock regardless of publish status or
* release date status. * release date status.
*/ */
"locked": null, "visible_to_staff_only": null,
/** /**
* Date of the last edit to this xblock or any of its descendants. * Date of the last edit to this xblock or any of its descendants.
*/ */
...@@ -69,7 +69,12 @@ define(["backbone", "underscore", "js/utils/module"], function(Backbone, _, Modu ...@@ -69,7 +69,12 @@ define(["backbone", "underscore", "js/utils/module"], function(Backbone, _, Modu
* this will either be the parent subsection or the grandparent section. * this will either be the parent subsection or the grandparent section.
* This can be null if the release date is unscheduled. * This can be null if the release date is unscheduled.
*/ */
"release_date_from":null "release_date_from":null,
/**
* True if this xblock is currently visible to students. This is computed server-side
* so that the logic isn't duplicated on the client.
*/
"currently_visible_to_students": null
}, },
parse: function(response) { parse: function(response) {
......
...@@ -48,7 +48,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -48,7 +48,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
); );
}; };
renderContainerPage = function(html, test, options) { renderContainerPage = function(test, html, options) {
requests = create_sinon.requests(test); requests = create_sinon.requests(test);
containerPage = new ContainerPage(_.extend(options || {}, { containerPage = new ContainerPage(_.extend(options || {}, {
model: model, model: model,
...@@ -70,7 +70,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -70,7 +70,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
describe("Initial display", function() { describe("Initial display", function() {
it('can render itself', function() { it('can render itself', function() {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
expect(containerPage.$('.xblock-header').length).toBe(9); expect(containerPage.$('.xblock-header').length).toBe(9);
expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden'); expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden');
}); });
...@@ -84,7 +84,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -84,7 +84,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
}); });
it('inline edits the display name when performing a new action', function() { it('inline edits the display name when performing a new action', function() {
renderContainerPage(mockContainerXBlockHtml, this, { renderContainerPage(this, mockContainerXBlockHtml, {
action: 'new' action: 'new'
}); });
expect(containerPage.$('.xblock-header').length).toBe(9); expect(containerPage.$('.xblock-header').length).toBe(9);
...@@ -106,8 +106,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -106,8 +106,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
}; };
expectEditCanceled = function(test, options) { expectEditCanceled = function(test, options) {
var initialRequests, displayNameWrapper; var initialRequests, displayNameWrapper, displayNameInput;
renderContainerPage(mockContainerXBlockHtml, test); renderContainerPage(test, mockContainerXBlockHtml);
displayNameWrapper = getDisplayNameWrapper(); displayNameWrapper = getDisplayNameWrapper();
initialRequests = requests.length; initialRequests = requests.length;
displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, options.newTitle); displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, options.newTitle);
...@@ -125,7 +125,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -125,7 +125,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('can edit itself', function() { it('can edit itself', function() {
var editButtons, displayNameElement; var editButtons, displayNameElement;
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
displayNameElement = containerPage.$('.page-header-title'); displayNameElement = containerPage.$('.page-header-title');
// Click the root edit button // Click the root edit button
...@@ -162,7 +162,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -162,7 +162,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('can inline edit the display name', function() { it('can inline edit the display name', function() {
var displayNameInput, displayNameWrapper; var displayNameInput, displayNameWrapper;
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
displayNameWrapper = getDisplayNameWrapper(); displayNameWrapper = getDisplayNameWrapper();
displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, updatedDisplayName); displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, updatedDisplayName);
displayNameInput.change(); displayNameInput.change();
...@@ -176,7 +176,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -176,7 +176,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('does not change the title when a display name update fails', function() { it('does not change the title when a display name update fails', function() {
var initialRequests, displayNameInput, displayNameWrapper; var initialRequests, displayNameInput, displayNameWrapper;
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
displayNameWrapper = getDisplayNameWrapper(); displayNameWrapper = getDisplayNameWrapper();
displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, updatedDisplayName); displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, updatedDisplayName);
initialRequests = requests.length; initialRequests = requests.length;
...@@ -190,7 +190,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -190,7 +190,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('trims whitespace from the display name', function() { it('trims whitespace from the display name', function() {
var displayNameInput, displayNameWrapper; var displayNameInput, displayNameWrapper;
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
displayNameWrapper = getDisplayNameWrapper(); displayNameWrapper = getDisplayNameWrapper();
displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, updatedDisplayName + ' '); displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, updatedDisplayName + ' ');
displayNameInput.change(); displayNameInput.change();
...@@ -222,7 +222,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -222,7 +222,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('can show an edit modal for a child xblock', function() { it('can show an edit modal for a child xblock', function() {
var editButtons; var editButtons;
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
editButtons = containerPage.$('.wrapper-xblock .edit-button'); editButtons = containerPage.$('.wrapper-xblock .edit-button');
// The container should have rendered six mock xblocks // The container should have rendered six mock xblocks
expect(editButtons.length).toBe(6); expect(editButtons.length).toBe(6);
...@@ -258,7 +258,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -258,7 +258,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('can save changes to settings', function() { it('can save changes to settings', function() {
var editButtons, modal, mockUpdatedXBlockHtml; var editButtons, modal, mockUpdatedXBlockHtml;
mockUpdatedXBlockHtml = readFixtures('mock/mock-updated-xblock.underscore'); mockUpdatedXBlockHtml = readFixtures('mock/mock-updated-xblock.underscore');
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
editButtons = containerPage.$('.wrapper-xblock .edit-button'); editButtons = containerPage.$('.wrapper-xblock .edit-button');
// The container should have rendered six mock xblocks // The container should have rendered six mock xblocks
expect(editButtons.length).toBe(6); expect(editButtons.length).toBe(6);
...@@ -346,24 +346,24 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -346,24 +346,24 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
}; };
it("can delete the first xblock", function() { it("can delete the first xblock", function() {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
deleteComponentWithSuccess(0); deleteComponentWithSuccess(0);
}); });
it("can delete a middle xblock", function() { it("can delete a middle xblock", function() {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
deleteComponentWithSuccess(1); deleteComponentWithSuccess(1);
}); });
it("can delete the last xblock", function() { it("can delete the last xblock", function() {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
deleteComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1); deleteComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
}); });
it('does not delete when clicking No in prompt', function () { it('does not delete when clicking No in prompt', function () {
var numRequests; var numRequests;
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
numRequests = requests.length; numRequests = requests.length;
// click delete on the first component but press no // click delete on the first component but press no
...@@ -378,7 +378,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -378,7 +378,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('shows a notification during the delete operation', function() { it('shows a notification during the delete operation', function() {
var notificationSpy = edit_helpers.createNotificationSpy(); var notificationSpy = edit_helpers.createNotificationSpy();
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
clickDelete(0); clickDelete(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/); edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/);
create_sinon.respondWithJson(requests, {}); create_sinon.respondWithJson(requests, {});
...@@ -387,7 +387,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -387,7 +387,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('does not delete an xblock upon failure', function () { it('does not delete an xblock upon failure', function () {
var notificationSpy = edit_helpers.createNotificationSpy(); var notificationSpy = edit_helpers.createNotificationSpy();
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
clickDelete(0); clickDelete(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/); edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/);
create_sinon.respondWithError(requests); create_sinon.respondWithError(requests);
...@@ -431,23 +431,23 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -431,23 +431,23 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
}; };
it("can duplicate the first xblock", function() { it("can duplicate the first xblock", function() {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
duplicateComponentWithSuccess(0); duplicateComponentWithSuccess(0);
}); });
it("can duplicate a middle xblock", function() { it("can duplicate a middle xblock", function() {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
duplicateComponentWithSuccess(1); duplicateComponentWithSuccess(1);
}); });
it("can duplicate the last xblock", function() { it("can duplicate the last xblock", function() {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
duplicateComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1); duplicateComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
}); });
it('shows a notification when duplicating', function () { it('shows a notification when duplicating', function () {
var notificationSpy = edit_helpers.createNotificationSpy(); var notificationSpy = edit_helpers.createNotificationSpy();
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
clickDuplicate(0); clickDuplicate(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/); edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
create_sinon.respondWithJson(requests, {"locator": "new_item"}); create_sinon.respondWithJson(requests, {"locator": "new_item"});
...@@ -456,7 +456,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -456,7 +456,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('does not duplicate an xblock upon failure', function () { it('does not duplicate an xblock upon failure', function () {
var notificationSpy = edit_helpers.createNotificationSpy(); var notificationSpy = edit_helpers.createNotificationSpy();
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
refreshXBlockSpies = spyOn(containerPage, "refreshXBlock"); refreshXBlockSpies = spyOn(containerPage, "refreshXBlock");
clickDuplicate(0); clickDuplicate(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/); edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
...@@ -475,7 +475,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -475,7 +475,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
}; };
it('sends the correct JSON to the server', function () { it('sends the correct JSON to the server', function () {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
clickNewComponent(0); clickNewComponent(0);
edit_helpers.verifyXBlockRequest(requests, { edit_helpers.verifyXBlockRequest(requests, {
"category": "discussion", "category": "discussion",
...@@ -486,7 +486,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -486,7 +486,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('shows a notification while creating', function () { it('shows a notification while creating', function () {
var notificationSpy = edit_helpers.createNotificationSpy(); var notificationSpy = edit_helpers.createNotificationSpy();
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
clickNewComponent(0); clickNewComponent(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Adding/); edit_helpers.verifyNotificationShowing(notificationSpy, /Adding/);
create_sinon.respondWithJson(requests, { }); create_sinon.respondWithJson(requests, { });
...@@ -495,7 +495,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -495,7 +495,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('does not insert component upon failure', function () { it('does not insert component upon failure', function () {
var requestCount; var requestCount;
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
clickNewComponent(0); clickNewComponent(0);
requestCount = requests.length; requestCount = requests.length;
create_sinon.respondWithError(requests); create_sinon.respondWithError(requests);
...@@ -514,7 +514,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -514,7 +514,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
verifyCreateHtmlComponent = function(test, templateIndex, expectedRequest) { verifyCreateHtmlComponent = function(test, templateIndex, expectedRequest) {
var xblockCount; var xblockCount;
renderContainerPage(mockContainerXBlockHtml, test); renderContainerPage(test, mockContainerXBlockHtml);
showTemplatePicker(); showTemplatePicker();
xblockCount = containerPage.$('.studio-xblock-wrapper').length; xblockCount = containerPage.$('.studio-xblock-wrapper').length;
containerPage.$('.new-component-html a')[templateIndex].click(); containerPage.$('.new-component-html a')[templateIndex].click();
......
...@@ -4,8 +4,9 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -4,8 +4,9 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
function ($, _, str, create_sinon, edit_helpers, Prompt, ContainerPage, ContainerSubviews, XBlockInfo) { function ($, _, str, create_sinon, edit_helpers, Prompt, ContainerPage, ContainerSubviews, XBlockInfo) {
describe("Container Subviews", function() { describe("Container Subviews", function() {
var model, containerPage, requests, renderContainerPage, respondWithHtml, respondWithJson, fetch, var model, containerPage, requests, createContainerPage, renderContainerPage,
disabledCss = "is-disabled", respondWithHtml, respondWithJson, fetch,
disabledCss = "is-disabled", defaultXBlockInfo, createXBlockInfo,
mockContainerPage = readFixtures('mock/mock-container-page.underscore'), mockContainerPage = readFixtures('mock/mock-container-page.underscore'),
mockContainerXBlockHtml = readFixtures('mock/mock-empty-container-xblock.underscore'); mockContainerXBlockHtml = readFixtures('mock/mock-empty-container-xblock.underscore');
...@@ -14,27 +15,39 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -14,27 +15,39 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
edit_helpers.installTemplate('publish-xblock'); edit_helpers.installTemplate('publish-xblock');
edit_helpers.installTemplate('publish-history'); edit_helpers.installTemplate('publish-history');
edit_helpers.installTemplate('unit-outline'); edit_helpers.installTemplate('unit-outline');
edit_helpers.installTemplate('container-message');
appendSetFixtures(mockContainerPage); appendSetFixtures(mockContainerPage);
});
model = new XBlockInfo({ defaultXBlockInfo = {
id: 'locator-container', id: 'locator-container',
display_name: 'Test Container', display_name: 'Test Container',
category: 'vertical', category: 'vertical',
published: false, published: false,
has_changes: false has_changes: false,
}, { edited_on: "Jul 02, 2014 at 14:20 UTC", edited_by: "joe",
parse: true published_on: "Jul 01, 2014 at 12:45 UTC", published_by: "amako",
}); visible_to_staff_only: false,
currently_visible_to_students: false
};
createXBlockInfo = function(options) {
return _.extend(_.extend({}, defaultXBlockInfo), options || {});
};
createContainerPage = function (test, options) {
requests = create_sinon.requests(test);
model = new XBlockInfo(createXBlockInfo(options), { parse: true });
containerPage = new ContainerPage({ containerPage = new ContainerPage({
model: model, model: model,
templates: edit_helpers.mockComponentTemplates, templates: edit_helpers.mockComponentTemplates,
el: $('#content'), el: $('#content'),
isUnitPage: true isUnitPage: true
}); });
}); };
renderContainerPage = function(html, that) { renderContainerPage = function (test, html, options) {
requests = create_sinon.requests(that); createContainerPage(test, options);
containerPage.render(); containerPage.render();
respondWithHtml(html); respondWithHtml(html);
}; };
...@@ -57,6 +70,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -57,6 +70,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
}; };
fetch = function (json) { fetch = function (json) {
json = createXBlockInfo(json);
model.fetch(); model.fetch();
respondWithJson(json); respondWithJson(json);
}; };
...@@ -66,30 +80,30 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -66,30 +80,30 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
previewCss = '.button-preview'; previewCss = '.button-preview';
it('renders correctly for private unit', function () { it('renders correctly for private unit', function () {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
expect(containerPage.$(viewPublishedCss)).toHaveClass(disabledCss); expect(containerPage.$(viewPublishedCss)).toHaveClass(disabledCss);
expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss); expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss);
}); });
it('updates when published attribute changes', function () { it('updates when published attribute changes', function () {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
fetch({"id": "locator-container", "published": true}); fetch({"published": true});
expect(containerPage.$(viewPublishedCss)).not.toHaveClass(disabledCss); expect(containerPage.$(viewPublishedCss)).not.toHaveClass(disabledCss);
fetch({"id": "locator-container", "published": false}); fetch({"published": false});
expect(containerPage.$(viewPublishedCss)).toHaveClass(disabledCss); expect(containerPage.$(viewPublishedCss)).toHaveClass(disabledCss);
}); });
it('updates when has_changes attribute changes', function () { it('updates when has_changes attribute changes', function () {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
fetch({"id": "locator-container", "has_changes": true}); fetch({"has_changes": true});
expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss); expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss);
fetch({"id": "locator-container", "published": true, "has_changes": false}); fetch({"published": true, "has_changes": false});
expect(containerPage.$(previewCss)).toHaveClass(disabledCss); expect(containerPage.$(previewCss)).toHaveClass(disabledCss);
// If published is false, preview is always enabled. // If published is false, preview is always enabled.
fetch({"id": "locator-container", "published": false, "has_changes": false}); fetch({"published": false, "has_changes": false});
expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss); expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss);
}); });
}); });
...@@ -97,21 +111,21 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -97,21 +111,21 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
describe("Publisher", function () { describe("Publisher", function () {
var headerCss = '.pub-status', var headerCss = '.pub-status',
bitPublishingCss = "div.bit-publishing", bitPublishingCss = "div.bit-publishing",
publishedBit = "published", publishedBit = "is-published",
draftBit = "draft", draftBit = "is-draft",
staffOnlyBit = "is-staff-only",
publishButtonCss = ".action-publish", publishButtonCss = ".action-publish",
discardChangesButtonCss = ".action-discard", discardChangesButtonCss = ".action-discard",
lastDraftCss = ".wrapper-last-draft", lastDraftCss = ".wrapper-last-draft",
releaseDateTitleCss = ".wrapper-release .title", releaseDateTitleCss = ".wrapper-release .title",
releaseDateContentCss = ".wrapper-release .copy", releaseDateContentCss = ".wrapper-release .copy",
lastRequest, promptSpies, sendDiscardChangesToServer; promptSpies, sendDiscardChangesToServer;
lastRequest = function() { return requests[requests.length - 1]; };
sendDiscardChangesToServer = function(test) { sendDiscardChangesToServer = function() {
// Helper function to do the discard operation, up until the server response. // Helper function to do the discard operation, up until the server response.
renderContainerPage(mockContainerXBlockHtml, test); containerPage.render();
fetch({"id": "locator-container", "published": true, "has_changes": true}); respondWithHtml(mockContainerXBlockHtml);
fetch({"published": true, "has_changes": true});
expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass('is-disabled'); expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass('is-disabled');
expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit); expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit);
// Click discard changes // Click discard changes
...@@ -132,30 +146,30 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -132,30 +146,30 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
}); });
it('renders correctly with private content', function () { it('renders correctly with private content', function () {
var verifyPrivateState = function(){ var verifyPrivateState = function() {
// State is the same regardless of "has_changes" value. expect(containerPage.$(headerCss).text()).toContain('Draft (Never published)');
expect(containerPage.$(headerCss).text()).toContain('Draft (Unpublished changes)');
expect(containerPage.$(publishButtonCss)).not.toHaveClass(disabledCss); expect(containerPage.$(publishButtonCss)).not.toHaveClass(disabledCss);
expect(containerPage.$(discardChangesButtonCss)).toHaveClass(disabledCss); expect(containerPage.$(discardChangesButtonCss)).toHaveClass(disabledCss);
expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit); expect(containerPage.$(bitPublishingCss)).not.toHaveClass(draftBit);
expect(containerPage.$(bitPublishingCss)).not.toHaveClass(publishedBit);
}; };
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
fetch({"id": "locator-container", "published": false, "has_changes": false}); fetch({"published": false, "has_changes": false});
verifyPrivateState(); verifyPrivateState();
fetch({"id": "locator-container", "published": false, "has_changes": true}); fetch({"published": false, "has_changes": true});
verifyPrivateState(); verifyPrivateState();
}); });
it('renders correctly with public content', function () { it('renders correctly with public content', function () {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
fetch({"id": "locator-container", "published": true, "has_changes": false}); fetch({"published": true, "has_changes": false});
expect(containerPage.$(headerCss).text()).toContain('Published'); expect(containerPage.$(headerCss).text()).toContain('Published');
expect(containerPage.$(publishButtonCss)).toHaveClass(disabledCss); expect(containerPage.$(publishButtonCss)).toHaveClass(disabledCss);
expect(containerPage.$(discardChangesButtonCss)).toHaveClass(disabledCss); expect(containerPage.$(discardChangesButtonCss)).toHaveClass(disabledCss);
expect(containerPage.$(bitPublishingCss)).toHaveClass(publishedBit); expect(containerPage.$(bitPublishingCss)).toHaveClass(publishedBit);
fetch({"id": "locator-container", "published": true, "has_changes": true}); fetch({"published": true, "has_changes": true});
expect(containerPage.$(headerCss).text()).toContain('Draft (Unpublished changes)'); expect(containerPage.$(headerCss).text()).toContain('Draft (Unpublished changes)');
expect(containerPage.$(publishButtonCss)).not.toHaveClass(disabledCss); expect(containerPage.$(publishButtonCss)).not.toHaveClass(disabledCss);
expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass(disabledCss); expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass(disabledCss);
...@@ -164,9 +178,10 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -164,9 +178,10 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('can publish private content', function () { it('can publish private content', function () {
var notificationSpy = edit_helpers.createNotificationSpy(); var notificationSpy = edit_helpers.createNotificationSpy();
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
fetch({"id": "locator-container", "published": false, "has_changes": false}); fetch({"published": false, "has_changes": false});
expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit); expect(containerPage.$(bitPublishingCss)).not.toHaveClass(draftBit);
expect(containerPage.$(bitPublishingCss)).not.toHaveClass(publishedBit);
// Click publish // Click publish
containerPage.$(publishButtonCss).click(); containerPage.$(publishButtonCss).click();
...@@ -191,9 +206,9 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -191,9 +206,9 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
}); });
it('can does not fetch if publish fails', function () { it('can does not fetch if publish fails', function () {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
fetch({"id": "locator-container", "published": false, "has_changes": false}); fetch({"published": false});
expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit); expect(containerPage.$(bitPublishingCss)).not.toHaveClass(publishedBit);
// Click publish // Click publish
containerPage.$(publishButtonCss).click(); containerPage.$(publishButtonCss).click();
...@@ -205,17 +220,18 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -205,17 +220,18 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
expect(requests.length).toEqual(numRequests); expect(requests.length).toEqual(numRequests);
// Verify still in draft state. // Verify still in draft state.
expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit); expect(containerPage.$(bitPublishingCss)).not.toHaveClass(publishedBit);
// Verify that the "published" value has been cleared out of the model. // Verify that the "published" value has been cleared out of the model.
expect(containerPage.model.get("publish")).toBeNull(); expect(containerPage.model.get("publish")).toBeNull();
}); });
it('can discard changes', function () { it('can discard changes', function () {
var notificationSpy = edit_helpers.createNotificationSpy(), var notificationSpy, renderPageSpy, numRequests;
renderPageSpy = spyOn(containerPage.xblockPublisher, 'renderPage').andCallThrough(), createContainerPage(this);
numRequests; notificationSpy = edit_helpers.createNotificationSpy();
renderPageSpy = spyOn(containerPage.xblockPublisher, 'renderPage').andCallThrough();
sendDiscardChangesToServer(this); sendDiscardChangesToServer();
numRequests = requests.length; numRequests = requests.length;
// Respond with success. // Respond with success.
...@@ -230,10 +246,11 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -230,10 +246,11 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
}); });
it('does not fetch if discard changes fails', function () { it('does not fetch if discard changes fails', function () {
var renderPageSpy = spyOn(containerPage.xblockPublisher, 'renderPage').andCallThrough(), var renderPageSpy, numRequests;
numRequests; createContainerPage(this);
renderPageSpy = spyOn(containerPage.xblockPublisher, 'renderPage').andCallThrough();
sendDiscardChangesToServer(this); sendDiscardChangesToServer();
numRequests = requests.length; numRequests = requests.length;
// Respond with failure // Respond with failure
...@@ -246,8 +263,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -246,8 +263,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
}); });
it('does not discard changes on cancel', function () { it('does not discard changes on cancel', function () {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
fetch({"id": "locator-container", "published": true, "has_changes": true}); fetch({"published": true, "has_changes": true});
var numRequests = requests.length; var numRequests = requests.length;
// Click discard changes // Click discard changes
...@@ -262,52 +279,49 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -262,52 +279,49 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
}); });
it('renders the last published date and user when there are no changes', function () { it('renders the last published date and user when there are no changes', function () {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
fetch({ "id": "locator-container", "has_changes": false, fetch({"published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako"});
"edited_on": "Jun 30, 2014 at 14:20 UTC", "edited_by": "joe",
"published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako"});
expect(containerPage.$(lastDraftCss).text()). expect(containerPage.$(lastDraftCss).text()).
toContain("Last published Jul 01, 2014 at 12:45 UTC by amako"); toContain("Last published Jul 01, 2014 at 12:45 UTC by amako");
}); });
it('renders the last saved date and user when there are changes', function () { it('renders the last saved date and user when there are changes', function () {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
fetch({ "id": "locator-container", "has_changes": true, fetch({"has_changes": true, "edited_on": "Jul 02, 2014 at 14:20 UTC", "edited_by": "joe"});
"edited_on": "Jul 02, 2014 at 14:20 UTC", "edited_by": "joe",
"published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako"});
expect(containerPage.$(lastDraftCss).text()). expect(containerPage.$(lastDraftCss).text()).
toContain("Draft saved on Jul 02, 2014 at 14:20 UTC by joe"); toContain("Draft saved on Jul 02, 2014 at 14:20 UTC by joe");
}); });
it('renders the release date correctly when unreleased', function () { describe("Release Date", function() {
renderContainerPage(mockContainerXBlockHtml, this); it('renders correctly when unreleased', function () {
fetch({ "id": "locator-container", "published": true, "released_to_students": false, renderContainerPage(this, mockContainerXBlockHtml);
fetch({"published": true, "released_to_students": false,
"release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"'}); "release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"'});
expect(containerPage.$(releaseDateTitleCss).text()).toContain("Scheduled:"); expect(containerPage.$(releaseDateTitleCss).text()).toContain("Scheduled:");
expect(containerPage.$(releaseDateContentCss).text()). expect(containerPage.$(releaseDateContentCss).text()).
toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"'); toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"');
}); });
it('renders the release date correctly when released', function () { it('renders correctly when released', function () {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
fetch({ "id": "locator-container", "published": true, "released_to_students": true, fetch({"published": true, "released_to_students": true,
"release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"' }); "release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"' });
expect(containerPage.$(releaseDateTitleCss).text()).toContain("Released:"); expect(containerPage.$(releaseDateTitleCss).text()).toContain("Released:");
expect(containerPage.$(releaseDateContentCss).text()). expect(containerPage.$(releaseDateContentCss).text()).
toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"'); toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"');
}); });
it('renders the release date correctly when the release date is not set', function () { it('renders correctly when the release date is not set', function () {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
fetch({ "id": "locator-container", "published": true, "released_to_students": false, fetch({"published": true, "released_to_students": false,
"release_date": null, "release_date_from": null }); "release_date": null, "release_date_from": null });
expect(containerPage.$(releaseDateTitleCss).text()).toContain("Release:"); expect(containerPage.$(releaseDateTitleCss).text()).toContain("Release:");
expect(containerPage.$(releaseDateContentCss).text()).toContain("Unscheduled"); expect(containerPage.$(releaseDateContentCss).text()).toContain("Unscheduled");
}); });
it('renders the release date correctly when the unit is not published', function () { it('renders correctly when the unit is not published', function () {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
fetch({ "id": "locator-container", "published": false, "released_to_students": true, fetch({"published": false, "released_to_students": true,
"release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"' }); "release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"' });
// Force a render because none of the fetched fields will trigger a render // Force a render because none of the fetched fields will trigger a render
containerPage.xblockPublisher.render(); containerPage.xblockPublisher.render();
...@@ -317,29 +331,161 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -317,29 +331,161 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
}); });
}); });
describe("Content Visibility", function () {
var requestStaffOnly, verifyStaffOnly, promptSpy;
requestStaffOnly = function(isStaffOnly) {
containerPage.$('.action-staff-lock').click();
// If removing the staff lock, click 'Yes' to confirm
if (!isStaffOnly) {
edit_helpers.confirmPrompt(promptSpy);
}
create_sinon.expectJsonRequest(requests, 'POST', '/xblock/locator-container', {
publish: 'republish',
metadata: { visible_to_staff_only: isStaffOnly }
});
create_sinon.respondWithJson(requests, {
data: null,
id: "locator-container",
metadata: {
visible_to_staff_only: isStaffOnly
}
});
create_sinon.expectJsonRequest(requests, 'GET', '/xblock/locator-container');
create_sinon.respondWithJson(requests, createXBlockInfo({
published: containerPage.model.get('published'),
visible_to_staff_only: isStaffOnly
}));
};
verifyStaffOnly = function(isStaffOnly) {
if (isStaffOnly) {
expect(containerPage.$('.action-staff-lock i')).toHaveClass('icon-check');
expect(containerPage.$('.wrapper-visibility .copy').text()).toBe('Staff Only');
expect(containerPage.$(bitPublishingCss)).toHaveClass(staffOnlyBit);
} else {
expect(containerPage.$('.action-staff-lock i')).toHaveClass('icon-check-empty');
expect(containerPage.$('.wrapper-visibility .copy').text()).toBe('Staff and Students');
expect(containerPage.$(bitPublishingCss)).not.toHaveClass(staffOnlyBit);
}
};
it("is initially shown to all", function() {
renderContainerPage(this, mockContainerXBlockHtml);
verifyStaffOnly(false);
});
it("can be set to staff only", function() {
renderContainerPage(this, mockContainerXBlockHtml);
containerPage.$('.action-staff-lock').click();
requestStaffOnly(true);
verifyStaffOnly(true);
});
it("can remove staff only setting", function() {
promptSpy = edit_helpers.createPromptSpy();
renderContainerPage(this, mockContainerXBlockHtml);
requestStaffOnly(true);
requestStaffOnly(false);
verifyStaffOnly(false);
expect(containerPage.$(bitPublishingCss)).not.toHaveClass(publishedBit);
});
it("can remove staff only setting from published unit", function() {
promptSpy = edit_helpers.createPromptSpy();
renderContainerPage(this, mockContainerXBlockHtml, { published: true });
requestStaffOnly(true);
requestStaffOnly(false);
verifyStaffOnly(false);
expect(containerPage.$(bitPublishingCss)).toHaveClass(publishedBit);
});
it("does not refresh if removing staff only is canceled", function() {
var requestCount;
promptSpy = edit_helpers.createPromptSpy();
renderContainerPage(this, mockContainerXBlockHtml);
requestStaffOnly(true);
requestCount = requests.length;
containerPage.$('.action-staff-lock').click();
edit_helpers.confirmPrompt(promptSpy, true); // Click 'No' to cancel
expect(requests.length).toBe(requestCount);
verifyStaffOnly(true);
});
it("does not refresh when failing to set staff only", function() {
var requestCount;
renderContainerPage(this, mockContainerXBlockHtml);
containerPage.$('.lock-checkbox').click();
requestCount = requests.length;
create_sinon.respondWithError(requests);
expect(requests.length).toBe(requestCount);
verifyStaffOnly(false);
});
});
});
describe("PublishHistory", function () { describe("PublishHistory", function () {
var lastPublishCss = ".wrapper-last-publish"; var lastPublishCss = ".wrapper-last-publish";
it('renders the last published date and user when the block is published', function () { it('renders the last published date and user when the block is published', function() {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
fetch({ "id": "locator-container", "published": true, fetch({
"published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako" }); "published": true, "published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako"
});
expect(containerPage.$(lastPublishCss).text()). expect(containerPage.$(lastPublishCss).text()).
toContain("Last published Jul 01, 2014 at 12:45 UTC by amako"); toContain("Last published Jul 01, 2014 at 12:45 UTC by amako");
}); });
it('renders never published when the block is unpublished', function () { it('renders never published when the block is unpublished', function () {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
fetch({ "id": "locator-container", "published": false, fetch({ "published": false });
"published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako" });
expect(containerPage.$(lastPublishCss).text()).toContain("Never published"); expect(containerPage.$(lastPublishCss).text()).toContain("Never published");
}); });
it('renders correctly when the block is published without publish info', function () { it('renders correctly when the block is published without publish info', function () {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(this, mockContainerXBlockHtml);
fetch({ "id": "locator-container", "published": true, "published_on": null, "published_by": null}); fetch({
"published": true, "published_on": null, "published_by": null
});
expect(containerPage.$(lastPublishCss).text()).toContain("Previously published"); expect(containerPage.$(lastPublishCss).text()).toContain("Previously published");
}); });
}); });
describe("Message Area", function() {
var messageSelector = '.container-message .warning',
warningMessage = 'This content is live for students. Edit with caution.';
it('is empty for a unit that is not currently visible to students', function() {
renderContainerPage(this, mockContainerXBlockHtml, {
currently_visible_to_students: false
});
expect(containerPage.$(messageSelector).text().trim()).toBe('');
});
it('shows a message for a unit that is currently visible to students', function() {
renderContainerPage(this, mockContainerXBlockHtml, {
currently_visible_to_students: true
});
expect(containerPage.$(messageSelector).text().trim()).toBe(warningMessage);
});
it('hides the message when the unit is hidden from students', function() {
renderContainerPage(this, mockContainerXBlockHtml, {
currently_visible_to_students: true
});
fetch({ currently_visible_to_students: false });
expect(containerPage.$(messageSelector).text().trim()).toBe('');
});
it('shows a message when a unit is made visible', function() {
renderContainerPage(this, mockContainerXBlockHtml, {
currently_visible_to_students: false
});
fetch({ currently_visible_to_students: true });
expect(containerPage.$(messageSelector).text().trim()).toBe(warningMessage);
});
});
}); });
}); });
...@@ -29,6 +29,11 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -29,6 +29,11 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
model: this.model, model: this.model,
view: this.view view: this.view
}); });
this.messageView = new ContainerSubviews.MessageView({
el: this.$('.container-message'),
model: this.model
});
this.messageView.render();
this.isUnitPage = this.options.isUnitPage; this.isUnitPage = this.options.isUnitPage;
if (this.isUnitPage) { if (this.isUnitPage) {
this.xblockPublisher = new ContainerSubviews.Publisher({ this.xblockPublisher = new ContainerSubviews.Publisher({
......
...@@ -10,7 +10,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ ...@@ -10,7 +10,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
* A view that calls render when "has_changes" or "published" values in XBlockInfo have changed * A view that calls render when "has_changes" or "published" values in XBlockInfo have changed
* after a server sync operation. * after a server sync operation.
*/ */
var UnitStateListenerView = BaseView.extend({ var ContainerStateListenerView = BaseView.extend({
// takes XBlockInfo as a model // takes XBlockInfo as a model
initialize: function() { initialize: function() {
...@@ -18,18 +18,43 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ ...@@ -18,18 +18,43 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
}, },
onSync: function(model) { onSync: function(model) {
if (ViewUtils.hasChangedAttributes(model, ['has_changes', 'published'])) { if (this.shouldRefresh(model)) {
this.render(); this.render();
} }
}, },
shouldRefresh: function(model) {
return false;
},
render: function() {} render: function() {}
}); });
var MessageView = ContainerStateListenerView.extend({
initialize: function () {
ContainerStateListenerView.prototype.initialize.call(this);
this.template = this.loadTemplate('container-message');
},
shouldRefresh: function(model) {
return ViewUtils.hasChangedAttributes(model, ['currently_visible_to_students']);
},
render: function() {
this.$el.html(this.template({
currentlyVisibleToStudents: this.model.get('currently_visible_to_students')
}));
return this;
}
});
/** /**
* A controller for updating the "View Live" and "Preview" buttons. * A controller for updating the "View Live" and "Preview" buttons.
*/ */
var PreviewActionController = UnitStateListenerView.extend({ var PreviewActionController = ContainerStateListenerView.extend({
shouldRefresh: function(model) {
return ViewUtils.hasChangedAttributes(model, ['has_changes', 'published']);
},
render: function() { render: function() {
var previewAction = this.$el.find('.button-preview'), var previewAction = this.$el.find('.button-preview'),
...@@ -59,7 +84,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ ...@@ -59,7 +84,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
var Publisher = BaseView.extend({ var Publisher = BaseView.extend({
events: { events: {
'click .action-publish': 'publish', 'click .action-publish': 'publish',
'click .action-discard': 'discardChanges' 'click .action-discard': 'discardChanges',
'click .action-staff-lock': 'toggleStaffLock'
}, },
// takes XBlockInfo as a model // takes XBlockInfo as a model
...@@ -72,22 +98,25 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ ...@@ -72,22 +98,25 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
}, },
onSync: function(model) { onSync: function(model) {
if (ViewUtils.hasChangedAttributes(model, ['has_changes', 'published', 'edited_on', 'edited_by'])) { if (ViewUtils.hasChangedAttributes(model, [
'has_changes', 'published', 'edited_on', 'edited_by', 'visible_to_staff_only'
])) {
this.render(); this.render();
} }
}, },
render: function () { render: function () {
this.$el.html(this.template({ this.$el.html(this.template({
has_changes: this.model.get('has_changes'), hasChanges: this.model.get('has_changes'),
published: this.model.get('published'), published: this.model.get('published'),
edited_on: this.model.get('edited_on'), editedOn: this.model.get('edited_on'),
edited_by: this.model.get('edited_by'), editedBy: this.model.get('edited_by'),
published_on: this.model.get('published_on'), publishedOn: this.model.get('published_on'),
published_by: this.model.get('published_by'), publishedBy: this.model.get('published_by'),
released_to_students: this.model.get('released_to_students'), releasedToStudents: this.model.get('released_to_students'),
release_date: this.model.get('release_date'), releaseDate: this.model.get('release_date'),
release_date_from: this.model.get('release_date_from') releaseDateFrom: this.model.get('release_date_from'),
visibleToStaffOnly: this.model.get('visible_to_staff_only')
})); }));
return this; return this;
...@@ -127,9 +156,59 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ ...@@ -127,9 +156,59 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
}); });
} }
); );
},
toggleStaffLock: function (e) {
var xblockInfo = this.model, self=this, enableStaffLock,
saveAndPublishStaffLock, revertCheckBox;
if (e && e.preventDefault) {
e.preventDefault();
} }
enableStaffLock = !xblockInfo.get('visible_to_staff_only');
revertCheckBox = function() {
self.checkStaffLock(!enableStaffLock);
};
saveAndPublishStaffLock = function() {
return xblockInfo.save({
publish: 'republish',
metadata: {visible_to_staff_only: enableStaffLock}},
{patch: true}
).always(function() {
xblockInfo.set("publish", null);
}).done(function () {
xblockInfo.fetch();
}).fail(function() {
revertCheckBox();
}); });
};
this.checkStaffLock(enableStaffLock);
if (enableStaffLock) {
ViewUtils.runOperationShowingMessage(gettext('Setting Staff Lock&hellip;'),
_.bind(saveAndPublishStaffLock, self));
} else {
ViewUtils.confirmThenRunOperation(gettext("Remove Staff Lock"),
gettext("Are you sure you want to remove the staff lock? Once you publish this unit, it will be released to students on the release date."),
gettext("Remove Staff Lock"),
function() {
ViewUtils.runOperationShowingMessage(gettext('Removing Staff Lock&hellip;'),
_.bind(saveAndPublishStaffLock, self));
},
function() {
// On cancel, revert the check in the check box
revertCheckBox();
}
);
}
},
checkStaffLock: function(check) {
this.$('.action-staff-lock i').removeClass('icon-check icon-check-empty');
this.$('.action-staff-lock i').addClass(check ? 'icon-check' : 'icon-check-empty');
}
});
/** /**
* PublishHistory displays when and by whom the xblock was last published, if it ever was. * PublishHistory displays when and by whom the xblock was last published, if it ever was.
...@@ -161,6 +240,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ ...@@ -161,6 +240,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
}); });
return { return {
'MessageView': MessageView,
'PreviewActionController': PreviewActionController, 'PreviewActionController': PreviewActionController,
'Publisher': Publisher, 'Publisher': Publisher,
'PublishHistory': PublishHistory 'PublishHistory': PublishHistory
......
...@@ -33,7 +33,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js ...@@ -33,7 +33,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
/** /**
* Confirms with the user whether to run an operation or not, and then runs it if desired. * Confirms with the user whether to run an operation or not, and then runs it if desired.
*/ */
confirmThenRunOperation = function(title, message, actionLabel, operation) { confirmThenRunOperation = function(title, message, actionLabel, operation, onCancelCallback) {
return new PromptView.Warning({ return new PromptView.Warning({
title: title, title: title,
message: message, message: message,
...@@ -48,6 +48,9 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js ...@@ -48,6 +48,9 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
secondary: { secondary: {
text: gettext('Cancel'), text: gettext('Cancel'),
click: function(prompt) { click: function(prompt) {
if (onCancelCallback) {
onCancelCallback();
}
return prompt.hide(); return prompt.hide();
} }
} }
......
...@@ -11,8 +11,7 @@ ...@@ -11,8 +11,7 @@
//.wrapper-xblock-header { //.wrapper-xblock-header {
.view-outline, .view-outline {
.view-container {
.add-xblock-component { .add-xblock-component {
text-align: center; text-align: center;
......
...@@ -111,11 +111,15 @@ ...@@ -111,11 +111,15 @@
&.staff-only, &.staff-only,
&.is-staff-only { &.is-staff-only {
@extend %bar-module-black; @extend %bar-module-black;
&.is-scheduled .wrapper-release .copy {
text-decoration: line-through;
}
} }
.bar-mod-content { .bar-mod-content {
border: 0; border: 0;
padding: ($baseline/2) ($baseline*.75) ($baseline*.75) ($baseline*.75); padding: ($baseline/2) ($baseline*.75) ($baseline/4) ($baseline*.75);
.title { .title {
margin-bottom: ($baseline/10); margin-bottom: ($baseline/10);
...@@ -123,7 +127,6 @@ ...@@ -123,7 +127,6 @@
} }
.wrapper-last-draft { .wrapper-last-draft {
padding: ($baseline*.75) ($baseline*.75) ($baseline/4) ($baseline*.75);
.date, .date,
.user { .user {
...@@ -145,9 +148,9 @@ ...@@ -145,9 +148,9 @@
font-weight: 600; font-weight: 600;
} }
.action-inline [class^="icon-"] { [class^="icon-"] {
margin: 0 ($baseline/4); margin-left: ($baseline/4);
color: $gray-d1;
} }
} }
...@@ -215,107 +218,7 @@ ...@@ -215,107 +218,7 @@
} }
.wrapper-unit-tree-location { .wrapper-unit-tree-location {
// tree location-specific styles should go here
.draggable-drop-indicator {
display: none;
}
// need to explicitly set this since the html structure is different than the others
.section-name:hover {
background: $blue-l5;
color: $blue;
}
.subsection,
.courseware-unit {
margin: ($baseline/4) 0 0 ($baseline*.75);
}
.courseware-unit .section-item {
background-color: transparent;
}
.section-item {
@include transition(background $tmg-avg ease-in-out 0);
@include box-sizing(border-box);
@extend %t-copy-sub2;
width: 100%;
display: inline-block;
vertical-align: top;
overflow: hidden;
padding: 6px 8px 8px 16px;
background: $gray-l5;
white-space: nowrap;
text-overflow: ellipsis;
color: $gray;
&:hover {
background: $blue-l5;
color: $blue;
}
&.editing {
background-color: $orange-l3;
}
// TODO: update these once we have different pub states
.public-item {
color: $black;
}
.private-item {
color: $gray-l1;
}
.draft-item {
color: $yellow-d1;
}
.public-item:hover,
.private-item:hover,
.draft-item:hover {
color: $blue;
}
.draft-item:after,
.public-item:after,
.private-item:after {
@include font-size(9);
margin-left: 3px;
font-weight: 600;
text-transform: uppercase;
}
.draft-item:after {
content: "- draft";
}
.private-item:after {
content: "- private";
}
}
.subsection > .section-item:hover {
background-color: $gray-l5;
color: inherit;
}
.new-unit-item {
@extend %ui-btn-flat-outline;
@extend %t-action4;
width: 90%;
margin: 0 0 ($baseline/2) ($baseline/4);
border: 1px solid transparent;
padding: ($baseline/4) ($baseline/2);
font-weight: normal;
color: $gray-l2;
text-align: left;
&:hover {
box-shadow: none;
background-image: none;
}
}
} }
} }
} }
......
...@@ -25,7 +25,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal", ...@@ -25,7 +25,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
"editor-mode-button", "upload-dialog", "image-modal", "editor-mode-button", "upload-dialog", "image-modal",
"add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu", "add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu",
"add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history", "add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history",
"unit-outline"] "unit-outline", "container-message"]
%> %>
<%block name="header_extras"> <%block name="header_extras">
% for template_name in templates: % for template_name in templates:
...@@ -116,16 +116,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal", ...@@ -116,16 +116,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
<section class="content-area"> <section class="content-area">
<article class="content-primary"> <article class="content-primary">
% if is_visible_to_students: <div class="container-message wrapper-message"></div>
<div class="container-message wrapper-message">
<div class="message has-warnings">
<p class="warning">
<i class="icon-warning-sign"></i>
${_("This content is live for students. Edit with caution.")}
</p>
</div>
</div>
% endif
<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" data-locator="${xblock_locator}" data-course-key="${xblock_locator.course_key}"> <section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" data-locator="${xblock_locator}" data-course-key="${xblock_locator.course_key}">
</section> </section>
<div class="ui-loading"> <div class="ui-loading">
......
<% if (currentlyVisibleToStudents) { %>
<div class="message has-warnings">
<p class="warning">
<i class="icon-warning-sign"></i>
<%= gettext("This content is live for students. Edit with caution.") %>
</p>
</div>
<% } %>
...@@ -14,8 +14,8 @@ ...@@ -14,8 +14,8 @@
<% if (xblockInfo.get('category') === 'vertical') { %> <% if (xblockInfo.get('category') === 'vertical') { %>
<a href="<%= xblockInfo.get('studio_url') %>"><%= xblockInfo.get('display_name') %></a> <a href="<%= xblockInfo.get('studio_url') %>"><%= xblockInfo.get('display_name') %></a>
<% } else { %> <% } else { %>
<span class="wrapper-xblock-field is-editable" data-field="display_name" data-field-display-name="<%= gettext("Display Name") %>"> <span class="wrapper-xblock-field incontext-editor is-editable" data-field="display_name" data-field-display-name="<%= gettext("Display Name") %>">
<span class="xblock-field-value"><%= xblockInfo.get('display_name') %></span> <span class="xblock-field-value incontext-editor-value"><%= xblockInfo.get('display_name') %></span>
</span> </span>
<% } %> <% } %>
</h3> </h3>
......
...@@ -43,6 +43,7 @@ ...@@ -43,6 +43,7 @@
<section class="content-area"> <section class="content-area">
<article class="content-primary window"> <article class="content-primary window">
<div class="container-message wrapper-message"></div>
<section class="wrapper-xblock level-page studio-xblock-wrapper" data-locator="locator-container"> <section class="wrapper-xblock level-page studio-xblock-wrapper" data-locator="locator-container">
</section> </section>
<div class="ui-loading is-hidden"> <div class="ui-loading is-hidden">
......
<div class="bit-publishing <% if (published && !has_changes) { %>published<% } else { %>draft<%} %>"> <%
var publishClasses = "";
var title = gettext("Draft (Never published)");
if (published) {
if (published && hasChanges) {
publishClasses = publishClasses + " is-draft";
title = gettext("Draft (Unpublished changes)");
} else {
publishClasses = publishClasses + " is-published";
title = gettext("Published");
}
}
if (releaseDate) {
publishClasses = publishClasses + " is-scheduled";
}
if (visibleToStaffOnly) {
publishClasses = publishClasses + " is-staff-only";
title = gettext("Unpublished (Staff only)");
}
%>
<div class="bit-publishing <%= publishClasses %>">
<h3 class="bar-mod-title pub-status"><span class="sr"><%= gettext("Publishing Status") %></span> <h3 class="bar-mod-title pub-status"><span class="sr"><%= gettext("Publishing Status") %></span>
<% if (published && !has_changes) { %> <%= title %>
<%= gettext("Published") %>
<% } else { %>
<%= gettext("Draft (Unpublished changes)") %>
<% } %>
</h3> </h3>
<div class="wrapper-last-draft bar-mod-content"> <div class="wrapper-last-draft bar-mod-content">
<p class="copy meta"> <p class="copy meta">
<% if (has_changes && edited_on && edited_by) { <% if (hasChanges && editedOn && editedBy) {
var message = gettext("Draft saved on %(last_saved_date)s by %(edit_username)s") %> var message = gettext("Draft saved on %(last_saved_date)s by %(edit_username)s") %>
<%= interpolate(message, { <%= interpolate(message, {
last_saved_date: '<span class="date">' + edited_on + '</span>', last_saved_date: '<span class="date">' + editedOn + '</span>',
edit_username: '<span class="user">' + edited_by + '</span>' }, true) %> edit_username: '<span class="user">' + editedBy + '</span>' }, true) %>
<% } else if (published_on && published_by) { <% } else if (publishedOn && publishedBy) {
var message = gettext("Last published %(last_published_date)s by %(publish_username)s"); %> var message = gettext("Last published %(last_published_date)s by %(publish_username)s"); %>
<%= interpolate(message, { <%= interpolate(message, {
last_published_date: '<span class="date">' + published_on + '</span>', last_published_date: '<span class="date">' + publishedOn + '</span>',
publish_username: '<span class="user">' + published_by + '</span>' }, true) %> publish_username: '<span class="user">' + publishedBy + '</span>' }, true) %>
<% } else { %> <% } else { %>
<%= gettext("Previously published") %> <%= gettext("Previously published") %>
<% } %> <% } %>
</p> </p>
</div> </div>
<!--TODO this needs strikeout styles once staff lock exists-->
<div class="wrapper-release bar-mod-content"> <div class="wrapper-release bar-mod-content">
<h5 class="title"> <h5 class="title">
<% if (published && release_date) { <% if (published && releaseDate) {
if (released_to_students) { %> if (releasedToStudents) { %>
<%= gettext("Released:") %> <%= gettext("Released:") %>
<% } else { %> <% } else { %>
<%= gettext("Scheduled:") %> <%= gettext("Scheduled:") %>
...@@ -39,37 +54,45 @@ ...@@ -39,37 +54,45 @@
<% } %> <% } %>
</h5> </h5>
<p class="copy"> <p class="copy">
<% if (release_date) { %> <% if (releaseDate) { %>
<% var message = gettext("%(release_date)s with %(section_or_subsection)s") %> <% var message = gettext("%(release_date)s with %(section_or_subsection)s") %>
<%= interpolate(message, { <%= interpolate(message, {
release_date: '<span class="release-date">' + release_date + '</span>', release_date: '<span class="release-date">' + releaseDate + '</span>',
section_or_subsection: '<span class="release-with">' + release_date_from + '</span>' }, true) %> section_or_subsection: '<span class="release-with">' + releaseDateFrom + '</span>' }, true) %>
<% } else { %> <% } else { %>
<%= gettext("Unscheduled") %> <%= gettext("Unscheduled") %>
<% } %> <% } %>
</p> </p>
</div> </div>
<!--To be added in STUD-1830--> <div class="wrapper-visibility bar-mod-content">
<!--<div class="wrapper-visibility bar-mod-content">--> <h5 class="title"><%= gettext("Will Be Visible To:") %></h5>
<!--<h5 class="title">Will be Visible to:</h5>--> <% if (visibleToStaffOnly) { %>
<!--<p class="copy">Staff and Students</p>--> <p class="copy"><%= gettext("Staff Only") %></p>
<!--<p class="action-inline">--> <% } else { %>
<!--<a href="">--> <p class="copy"><%= gettext("Staff and Students") %></p>
<!--<i class="icon-unlock is-disabled"></i> Hide from Students--> <% } %>
<!--</a>--> <p class="action-inline">
<!--</p>--> <a href="" class="action-staff-lock" role="button" aria-pressed="<%= visibleToStaffOnly %>">
<!--</div>--> <% if (visibleToStaffOnly) { %>
<i class="icon-check"></i>
<% } else { %>
<i class="icon-check-empty"></i>
<% } %>
<%= gettext('Hide from students') %>
</a>
</p>
</div>
<div class="wrapper-pub-actions bar-mod-actions"> <div class="wrapper-pub-actions bar-mod-actions">
<ul class="action-list"> <ul class="action-list">
<li class="action-item"> <li class="action-item">
<a class="action-publish action-primary <% if (published && !has_changes) { %>is-disabled<% } %>" <a class="action-publish action-primary <% if (published && !hasChanges) { %>is-disabled<% } %>"
href=""><%= gettext("Publish") %> href=""><%= gettext("Publish") %>
</a> </a>
</li> </li>
<li class="action-item"> <li class="action-item">
<a class="action-discard action-secondary <% if (!published || !has_changes) { %>is-disabled<% } %>" <a class="action-discard action-secondary <% if (!published || !hasChanges) { %>is-disabled<% } %>"
href=""><%= gettext("Discard Changes") %> href=""><%= gettext("Discard Changes") %>
</a> </a>
</li> </li>
......
...@@ -10,16 +10,24 @@ class StaffPage(PageObject): ...@@ -10,16 +10,24 @@ class StaffPage(PageObject):
""" """
url = None url = None
STAFF_STATUS_CSS = '#staffstatus'
def is_browser_on_page(self): def is_browser_on_page(self):
return self.q(css='#staffstatus').present return self.q(css=self.STAFF_STATUS_CSS).present
@property @property
def staff_status(self): def staff_status(self):
""" """
Return the current status, either Staff view or Student view Return the current status, either Staff view or Student view
""" """
return self.q(css='#staffstatus').text[0] return self.q(css=self.STAFF_STATUS_CSS).text[0]
def toggle_staff_view(self):
"""
Toggle between staff view and student view.
"""
self.q(css=self.STAFF_STATUS_CSS).first.click()
self.wait_for_ajax()
def open_staff_debug_info(self): def open_staff_debug_info(self):
""" """
......
...@@ -82,6 +82,33 @@ class ContainerPage(PageObject): ...@@ -82,6 +82,33 @@ class ContainerPage(PageObject):
return self.q(css='.pub-status').first.text[0] return self.q(css='.pub-status').first.text[0]
@property @property
def release_title(self):
"""
Returns the title before the release date in the publishing sidebar component.
"""
return self.q(css='.wrapper-release .title').first.text[0]
@property
def release_date(self):
"""
Returns the release date of the unit (with ancestor inherited from), as displayed
in the publishing sidebar component.
"""
return self.q(css='.wrapper-release .copy').first.text[0]
@property
def currently_visible_to_students(self):
"""
Returns True if the unit is marked as currently visible to students
(meaning that a warning is being displayed).
"""
warnings = self.q(css='.container-message .warning')
if not warnings.is_present():
return False
warning_text = warnings.first.text[0]
return warning_text == "This content is live for students. Edit with caution."
@property
def publish_action(self): def publish_action(self):
""" """
Returns the link for publishing a unit. Returns the link for publishing a unit.
...@@ -96,6 +123,22 @@ class ContainerPage(PageObject): ...@@ -96,6 +123,22 @@ class ContainerPage(PageObject):
self.q(css='a.button.action-primary').first.click() self.q(css='a.button.action-primary').first.click()
self.wait_for_ajax() self.wait_for_ajax()
def toggle_staff_lock(self):
"""
Toggles "hide from students" which enables or disables a staff-only lock.
Returns True if the lock is now enabled, else False.
"""
class_attribute_values = self.q(css='a.action-staff-lock>i').attrs('class')
was_locked_initially = 'icon-check' in class_attribute_values
if not was_locked_initially:
self.q(css='a.action-staff-lock').first.click()
else:
click_css(self, 'a.action-staff-lock', 0, require_notification=False)
self.q(css='a.button.action-primary').first.click()
self.wait_for_ajax()
return not was_locked_initially
def view_published_version(self): def view_published_version(self):
""" """
Clicks "View Published Version", which will open the published version of the unit page in the LMS. Clicks "View Published Version", which will open the published version of the unit page in the LMS.
......
...@@ -16,6 +16,7 @@ from ..pages.lms.progress import ProgressPage ...@@ -16,6 +16,7 @@ from ..pages.lms.progress import ProgressPage
from ..pages.lms.dashboard import DashboardPage from ..pages.lms.dashboard import DashboardPage
from ..pages.lms.video.video import VideoPage from ..pages.lms.video.video import VideoPage
from ..pages.xblock.acid import AcidView from ..pages.xblock.acid import AcidView
from ..pages.lms.courseware import CoursewarePage
from ..fixtures.course import CourseFixture, XBlockFixtureDesc, CourseUpdateDesc from ..fixtures.course import CourseFixture, XBlockFixtureDesc, CourseUpdateDesc
...@@ -421,3 +422,87 @@ class XBlockAcidChildTest(XBlockAcidBase): ...@@ -421,3 +422,87 @@ class XBlockAcidChildTest(XBlockAcidBase):
@skip('This will fail until we fix support of children in pure XBlocks') @skip('This will fail until we fix support of children in pure XBlocks')
def test_acid_block(self): def test_acid_block(self):
super(XBlockAcidChildTest, self).test_acid_block() super(XBlockAcidChildTest, self).test_acid_block()
class VisibleToStaffOnlyTest(UniqueCourseTest):
"""
Tests that content with visible_to_staff_only set to True cannot be viewed by students.
"""
def setUp(self):
super(VisibleToStaffOnlyTest, self).setUp()
course_fix = CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
)
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Subsection With Locked Unit').add_children(
XBlockFixtureDesc('vertical', 'Locked Unit', metadata={'visible_to_staff_only': True}).add_children(
XBlockFixtureDesc('html', 'Html Child in locked unit', data="<html>Visible only to staff</html>"),
),
XBlockFixtureDesc('vertical', 'Unlocked Unit').add_children(
XBlockFixtureDesc('html', 'Html Child in unlocked unit', data="<html>Visible only to all</html>"),
)
),
XBlockFixtureDesc('sequential', 'Unlocked Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
XBlockFixtureDesc('html', 'Html Child in visible unit', data="<html>Visible to all</html>"),
)
),
XBlockFixtureDesc('sequential', 'Locked Subsection', metadata={'visible_to_staff_only': True}).add_children(
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
XBlockFixtureDesc(
'html', 'Html Child in locked subsection', data="<html>Visible only to staff</html>"
)
)
)
)
).install()
self.courseware_page = CoursewarePage(self.browser, self.course_id)
self.course_nav = CourseNavPage(self.browser)
def test_visible_to_staff(self):
"""
Scenario: All content is visible for a user marked is_staff (different from course staff)
Given some of the course content has been marked 'visible_to_staff_only'
And I am logged on with an account marked 'is_staff'
Then I can see all course content
"""
AutoAuthPage(self.browser, username="STAFF_TESTER", email="johndoe_staff@example.com",
course_id=self.course_id, staff=True).visit()
self.courseware_page.visit()
self.assertEqual(3, len(self.course_nav.sections['Test Section']))
self.course_nav.go_to_section("Test Section", "Subsection With Locked Unit")
self.assertEqual(["Html Child in locked unit", "Html Child in unlocked unit"], self.course_nav.sequence_items)
self.course_nav.go_to_section("Test Section", "Unlocked Subsection")
self.assertEqual(["Html Child in visible unit"], self.course_nav.sequence_items)
self.course_nav.go_to_section("Test Section", "Locked Subsection")
self.assertEqual(["Html Child in locked subsection"], self.course_nav.sequence_items)
def test_visible_to_student(self):
"""
Scenario: Content marked 'visible_to_staff_only' is not visible for students in the course
Given some of the course content has been marked 'visible_to_staff_only'
And I am logged on with an authorized student account
Then I can only see content without 'visible_to_staff_only' set to True
"""
AutoAuthPage(self.browser, username="STUDENT_TESTER", email="johndoe_student@example.com",
course_id=self.course_id, staff=False).visit()
self.courseware_page.visit()
self.assertEqual(2, len(self.course_nav.sections['Test Section']))
self.course_nav.go_to_section("Test Section", "Subsection With Locked Unit")
self.assertEqual(["Html Child in unlocked unit"], self.course_nav.sequence_items)
self.course_nav.go_to_section("Test Section", "Unlocked Subsection")
self.assertEqual(["Html Child in visible unit"], self.course_nav.sequence_items)
...@@ -11,9 +11,12 @@ from ..fixtures.course import XBlockFixtureDesc ...@@ -11,9 +11,12 @@ from ..fixtures.course import XBlockFixtureDesc
from ..pages.studio.component_editor import ComponentEditorView from ..pages.studio.component_editor import ComponentEditorView
from ..pages.studio.utils import add_discussion from ..pages.studio.utils import add_discussion
from ..pages.lms.courseware import CoursewarePage from ..pages.lms.courseware import CoursewarePage
from ..pages.lms.staff_view import StaffPage
from unittest import skip from unittest import skip
from acceptance.tests.base_studio_test import StudioCourseTest from acceptance.tests.base_studio_test import StudioCourseTest
import datetime
from bok_choy.promise import Promise
@attr('shard_1') @attr('shard_1')
...@@ -46,15 +49,15 @@ class ContainerBase(StudioCourseTest): ...@@ -46,15 +49,15 @@ class ContainerBase(StudioCourseTest):
container = unit.xblocks[1].go_to_container() container = unit.xblocks[1].go_to_container()
return container return container
def go_to_unit_page(self): def go_to_unit_page(self, section_name='Test Section', subsection_name='Test Subsection', unit_name='Test Unit'):
""" """
Go to the test unit page. Go to the test unit page.
If make_draft is true, the unit page will be put into draft mode. If make_draft is true, the unit page will be put into draft mode.
""" """
self.outline.visit() self.outline.visit()
subsection = self.outline.section('Test Section').subsection('Test Subsection') subsection = self.outline.section(section_name).subsection(subsection_name)
return subsection.toggle_expand().unit('Test Unit').go_to() return subsection.toggle_expand().unit(unit_name).go_to()
def verify_ordering(self, container, expected_orderings): def verify_ordering(self, container, expected_orderings):
""" """
...@@ -379,6 +382,8 @@ class UnitPublishingTest(ContainerBase): ...@@ -379,6 +382,8 @@ class UnitPublishingTest(ContainerBase):
PUBLISHED_STATUS = "Publishing Status\nPublished" PUBLISHED_STATUS = "Publishing Status\nPublished"
DRAFT_STATUS = "Publishing Status\nDraft (Unpublished changes)" DRAFT_STATUS = "Publishing Status\nDraft (Unpublished changes)"
LOCKED_STATUS = "Publishing Status\nUnpublished (Staff only)"
RELEASE_TITLE_RELEASED = "RELEASED:"
def setup_fixtures(self): def setup_fixtures(self):
""" """
...@@ -393,6 +398,8 @@ class UnitPublishingTest(ContainerBase): ...@@ -393,6 +398,8 @@ class UnitPublishingTest(ContainerBase):
self.course_info['run'], self.course_info['run'],
self.course_info['display_name'] self.course_info['display_name']
) )
past_start_date = datetime.datetime(1974, 6, 22)
self.past_start_date_text = "Jun 22, 1974 at 00:00 UTC"
course_fix.add_children( course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children( XBlockFixtureDesc('chapter', 'Test Section').add_children(
...@@ -401,6 +408,20 @@ class UnitPublishingTest(ContainerBase): ...@@ -401,6 +408,20 @@ class UnitPublishingTest(ContainerBase):
XBlockFixtureDesc('html', 'Test html', data=self.html_content) XBlockFixtureDesc('html', 'Test html', data=self.html_content)
) )
) )
),
XBlockFixtureDesc('chapter', 'Unlocked Section', metadata={'start': past_start_date.isoformat()}).add_children(
XBlockFixtureDesc('sequential', 'Unlocked Subsection').add_children(
XBlockFixtureDesc('vertical', 'Unlocked Unit').add_children(
XBlockFixtureDesc('problem', '<problem></problem>', data=self.html_content)
)
)
),
XBlockFixtureDesc('chapter', 'Section With Locked Unit').add_children(
XBlockFixtureDesc('sequential', 'Subsection With Locked Unit', metadata={'start': past_start_date.isoformat()}).add_children(
XBlockFixtureDesc('vertical', 'Locked Unit', metadata={'visible_to_staff_only': True}).add_children(
XBlockFixtureDesc('discussion', '', data=self.html_content)
)
)
) )
).install() ).install()
...@@ -408,61 +429,220 @@ class UnitPublishingTest(ContainerBase): ...@@ -408,61 +429,220 @@ class UnitPublishingTest(ContainerBase):
def test_publishing(self): def test_publishing(self):
""" """
Test the state changes when a published unit has draft changes. Scenario: The publish title changes based on whether or not draft content exists
Given I have a published unit with no unpublished changes
When I go to the unit page in Studio
Then the title in the Publish information box is "Published"
And the Publish button is disabled
And when I add a component to the unit
Then the title in the Publish information box is "Draft (Unpublished changes)"
And the Publish button is enabled
And when I click the Publish button
Then the title in the Publish information box is "Published"
""" """
unit = self.go_to_unit_page() unit = self.go_to_unit_page()
self.assertEqual(self.PUBLISHED_STATUS, unit.publish_title) self._verify_publish_title(unit, self.PUBLISHED_STATUS)
# Start date set in course fixture to 1970.
self._verify_release_date_info(
unit, self.RELEASE_TITLE_RELEASED, 'Jan 01, 1970 at 00:00 UTC with Section "Test Section"'
)
# Should not be able to click on Publish action -- but I don't know how to test that it is not clickable. # Should not be able to click on Publish action -- but I don't know how to test that it is not clickable.
# TODO: continue discussion with Muhammad and Jay about this. # TODO: continue discussion with Muhammad and Jay about this.
# Add a component to the page so it will have unpublished changes. # Add a component to the page so it will have unpublished changes.
add_discussion(unit) add_discussion(unit)
self.assertEqual(self.DRAFT_STATUS, unit.publish_title) self._verify_publish_title(unit, self.DRAFT_STATUS)
unit.publish_action.click() unit.publish_action.click()
unit.wait_for_ajax() unit.wait_for_ajax()
self.assertEqual(self.PUBLISHED_STATUS, unit.publish_title) self._verify_publish_title(unit, self.PUBLISHED_STATUS)
def test_discard_changes(self): def test_discard_changes(self):
""" """
Test the state after discard changes. Scenario: The publish title changes after "Discard Changes" is clicked
Given I have a published unit with no unpublished changes
When I go to the unit page in Studio
Then the Discard Changes button is disabled
And I add a component to the unit
Then the title in the Publish information box is "Draft (Unpublished changes)"
And the Discard Changes button is enabled
And when I click the Discard Changes button
Then the title in the Publish information box is "Published"
""" """
unit = self.go_to_unit_page() unit = self.go_to_unit_page()
add_discussion(unit) add_discussion(unit)
self.assertEqual(self.DRAFT_STATUS, unit.publish_title) self._verify_publish_title(unit, self.DRAFT_STATUS)
unit.discard_changes() unit.discard_changes()
self.assertEqual(self.PUBLISHED_STATUS, unit.publish_title) self._verify_publish_title(unit, self.PUBLISHED_STATUS)
def test_view_live_no_changes(self): def test_view_live_no_changes(self):
""" """
Tests viewing of live with initial published content. Scenario: "View Live" shows published content in LMS
Given I have a published unit with no unpublished changes
When I go to the unit page in Studio
Then the View Live button is enabled
And when I click on the View Live button
Then I see the published content in LMS
""" """
unit = self.go_to_unit_page() unit = self.go_to_unit_page()
unit.view_published_version() unit.view_published_version()
self.assertEqual(1, self.courseware.num_xblock_components) self._verify_components_visible(['html'])
self.assertEqual('html', self.courseware.xblock_component_type(0))
def test_view_live_changes(self): def test_view_live_changes(self):
""" """
Tests that viewing of live with draft content does not show the draft content. Scenario: "View Live" does not show draft content in LMS
Given I have a published unit with no unpublished changes
When I go to the unit page in Studio
And when I add a component to the unit
And when I click on the View Live button
Then I see the published content in LMS
And I do not see the unpublished component
""" """
unit = self.go_to_unit_page() unit = self.go_to_unit_page()
add_discussion(unit) add_discussion(unit)
unit.view_published_version() unit.view_published_version()
self.assertEqual(1, self.courseware.num_xblock_components) self._verify_components_visible(['html'])
self.assertEqual('html', self.courseware.xblock_component_type(0))
self.assertEqual(self.html_content, self.courseware.xblock_component_html_content(0)) self.assertEqual(self.html_content, self.courseware.xblock_component_html_content(0))
def test_view_live_after_publish(self): def test_view_live_after_publish(self):
""" """
Tests viewing of live after creating draft and publishing it. Scenario: "View Live" shows newly published content
Given I have a published unit with no unpublished changes
When I go to the unit page in Studio
And when I add a component to the unit
And when I click the Publish button
And when I click on the View Live button
Then I see the newly published component
""" """
unit = self.go_to_unit_page() unit = self.go_to_unit_page()
add_discussion(unit) add_discussion(unit)
unit.publish_action.click() unit.publish_action.click()
unit.view_published_version() unit.view_published_version()
self.assertEqual(2, self.courseware.num_xblock_components) self._verify_components_visible(['html', 'discussion'])
self.assertEqual('html', self.courseware.xblock_component_type(0))
self.assertEqual('discussion', self.courseware.xblock_component_type(1)) def test_initially_unlocked_visible_to_students(self):
"""
Scenario: An unlocked unit with release date in the past is visible to students
Given I have a published unlocked unit with release date in the past
When I go to the unit page in Studio
Then the unit has a warning that it is visible to students
And it is marked as "RELEASED" with release date in the past visible
And when I click on the View Live Button
And when I view the course as a student
Then I see the content in the unit
"""
unit = self.go_to_unit_page("Unlocked Section", "Unlocked Subsection", "Unlocked Unit")
self._verify_publish_title(unit, self.PUBLISHED_STATUS)
self.assertTrue(unit.currently_visible_to_students)
self._verify_release_date_info(
unit, self.RELEASE_TITLE_RELEASED, self.past_start_date_text + ' with Section "Unlocked Section"'
)
unit.view_published_version()
self._verify_student_view_visible(['problem'])
def test_locked_visible_to_staff_only(self):
"""
Scenario: After locking a unit with release date in the past, it is only visible to staff
Given I have a published unlocked unit with release date in the past
When I go to the unit page in Studio
And when I select "Hide from students"
Then the unit does not have a warning that it is visible to students
And when I click on the View Live Button
Then I see the content in the unit when logged in as staff
And when I view the course as a student
Then I do not see any content in the unit
"""
unit = self.go_to_unit_page("Unlocked Section", "Unlocked Subsection", "Unlocked Unit")
checked = unit.toggle_staff_lock()
self.assertTrue(checked)
self.assertFalse(unit.currently_visible_to_students)
self._verify_publish_title(unit, self.LOCKED_STATUS)
unit.view_published_version()
# Will initially be in staff view, locked component should be visible.
self._verify_components_visible(['problem'])
# Switch to student view and verify not visible
self._verify_student_view_locked()
def test_initially_locked_not_visible_to_students(self):
"""
Scenario: A locked unit with release date in the past is not visible to students
Given I have a published locked unit with release date in the past
When I go to the unit page in Studio
Then the unit does not have a warning that it is visible to students
And it is marked as "RELEASED" with release date in the past visible
And when I click on the View Live Button
And when I view the course as a student
Then I do not see any content in the unit
"""
unit = self.go_to_unit_page("Section With Locked Unit", "Subsection With Locked Unit", "Locked Unit")
self._verify_publish_title(unit, self.LOCKED_STATUS)
self.assertFalse(unit.currently_visible_to_students)
self._verify_release_date_info(
unit, self.RELEASE_TITLE_RELEASED,
self.past_start_date_text + ' with Subsection "Subsection With Locked Unit"'
)
unit.view_published_version()
self._verify_student_view_locked()
def test_unlocked_visible_to_all(self):
"""
Scenario: After unlocking a unit with release date in the past, it is visible to both students and staff
Given I have a published unlocked unit with release date in the past
When I go to the unit page in Studio
And when I deselect "Hide from students"
Then the unit does have a warning that it is visible to students
And when I click on the View Live Button
Then I see the content in the unit when logged in as staff
And when I view the course as a student
Then I see the content in the unit
"""
unit = self.go_to_unit_page("Section With Locked Unit", "Subsection With Locked Unit", "Locked Unit")
checked = unit.toggle_staff_lock()
self.assertFalse(checked)
self._verify_publish_title(unit, self.PUBLISHED_STATUS)
self.assertTrue(unit.currently_visible_to_students)
unit.view_published_version()
# Will initially be in staff view, components always visible.
self._verify_components_visible(['discussion'])
# Switch to student view and verify visible.
self._verify_student_view_visible(['discussion'])
def _verify_student_view_locked(self):
"""
Verifies no component is visible when viewing as a student.
"""
StaffPage(self.browser).toggle_staff_view()
self.assertEqual(0, self.courseware.num_xblock_components)
def _verify_student_view_visible(self, expected_components):
"""
Verifies expected components are visible when viewing as a student.
"""
StaffPage(self.browser).toggle_staff_view()
self._verify_components_visible(expected_components)
def _verify_components_visible(self, expected_components):
"""
Verifies the expected components are visible (and there are no extras).
"""
self.assertEqual(len(expected_components), self.courseware.num_xblock_components)
for index, component in enumerate(expected_components):
self.assertEqual(component, self.courseware.xblock_component_type(index))
def _verify_release_date_info(self, unit, expected_title, expected_date):
"""
Verifies how the release date is displayed in the publishing sidebar.
"""
self.assertEqual(expected_title, unit.release_title)
self.assertEqual(expected_date, unit.release_date)
def _verify_publish_title(self, unit, expected_title):
"""
Waits for the publish title to change to the expected value.
"""
def wait_for_title_change():
return (unit.publish_title == expected_title, unit.publish_title)
Promise(wait_for_title_change, "Publish title incorrect. Found '" + unit.publish_title + "'").fulfill()
# TODO: need to work with Jay/Christine to get testing of "Preview" working. # TODO: need to work with Jay/Christine to get testing of "Preview" working.
# def test_preview(self): # def test_preview(self):
......
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