diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index c87db7e076..d6bbd503f1 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -231,7 +231,7 @@ class XBlockVisibilityTestCase(TestCase): vertical.start = self.future 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): """ @@ -239,13 +239,13 @@ class XBlockVisibilityTestCase(TestCase): 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) - 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. staff_lock = self._create_xblock_with_start_date( 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): """Helper to create an xblock with a start date, optionally publishing it""" diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 9b1b935cee..167ad9e81d 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -164,9 +164,10 @@ def 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: diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 193b0e088d..f9a657addf 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -21,7 +21,7 @@ from xblock.fields import Scope from xblock.plugin import PluginMissingError 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.item import create_xblock_info @@ -207,7 +207,6 @@ def container_handler(request, usage_key_string): 'xblock_locator': xblock.location, 'unit': unit, 'is_unit_page': is_unit_page, - 'is_visible_to_students': is_xblock_visible_to_students(xblock), 'subsection': subsection, 'section': section, 'new_unit_category': 'vertical', diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index efd6ef6526..1cef9d0e09 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -37,6 +37,7 @@ from util.date_utils import get_default_time_display from util.json_request import expect_json, JsonResponse 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, \ xblock_type_display_name, get_parent_xblock from contentstore.views.preview import get_preview_fragment @@ -104,10 +105,12 @@ def xblock_handler(request, usage_key_string): to None! Absent ones will be left alone. :nullout: which metadata fields to set to None :graderType: change how this unit is graded - :publish: can be either -- 'make_public' (which publishes the content) or 'discard_changes' - (which reverts to the last published version). 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. + :publish: can be: + 'make_public': publish the content + 'republish': publish this item *only* if it was previously published + '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. 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): # 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)) # 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) else: return HttpResponse(status=406) @@ -145,9 +148,9 @@ def xblock_handler(request, usage_key_string): _delete_item(usage_key, request.user) return JsonResponse() else: # Since we have a usage_key, we are updating an existing xblock. - return _save_item( + return _save_xblock( request.user, - usage_key, + _get_xblock(usage_key, request.user), data=request.json.get('data'), children=request.json.get('children'), metadata=request.json.get('metadata'), @@ -289,8 +292,8 @@ def xblock_outline_handler(request, usage_key_string): return Http404 -def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout=None, - grader_type=None, publish=None): +def _save_xblock(user, xblock, data=None, children=None, metadata=None, nullout=None, + grader_type=None, publish=None): """ Saves xblock w/ its fields. Has special processing for grader_type, publish, and nullout and Nones in metadata. nullout means to truly set the field to None whereas nones in metadata mean to unset them (so they revert @@ -298,32 +301,19 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout """ 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). 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, # 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_content = existing_item.get_explicitly_set_fields_by_scope(Scope.content) + old_metadata = own_metadata(xblock) + old_content = xblock.get_explicitly_set_fields_by_scope(Scope.content) if data: # TODO Allow any scope.content fields not just "data" (exactly like the get below this) - existing_item.data = data + xblock.data = data else: 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 for child in children: child_usage_key = usage_key_with_run(child) 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 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 # 'apply' the submitted metadata, so we don't end up deleting system metadata. if nullout is not None: 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) # 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 if metadata is not None: for metadata_key, value in metadata.items(): - field = existing_item.fields[metadata_key] + field = xblock.fields[metadata_key] if value is None: - field.delete_from(existing_item) + field.delete_from(xblock) else: try: value = field.from_json(value) except ValueError: 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)): - existing_item.editor_saved(user, old_metadata, old_content) + if callable(getattr(xblock, "editor_saved", None)): + xblock.editor_saved(user, old_metadata, old_content) # 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 - if usage_key.category == 'static_tab': - course = store.get_course(usage_key.course_key) + if xblock.location.category == 'static_tab': + course = store.get_course(xblock.location.course_key) # 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 - if static_tab and static_tab['name'] != existing_item.display_name: - static_tab['name'] = existing_item.display_name + if static_tab and static_tab['name'] != xblock.display_name: + static_tab['name'] = xblock.display_name store.update_item(course, user.id) result = { - 'id': unicode(usage_key), + 'id': unicode(xblock.location), 'data': data, - 'metadata': own_metadata(existing_item) + 'metadata': own_metadata(xblock) } 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. - # 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': - 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. return JsonResponse(result) @@ -552,31 +550,40 @@ def orphan_handler(request, course_key_string): raise PermissionDenied() -def _get_module_info(usage_key, user, rewrite_static_links=True): +def _get_xblock(usage_key, user): + """ + Returns the xblock for the specified usage key. Note: if failing to find a key with a category + in the CREATE_IF_NOT_FOUND list, an xblock will be created and saved automatically. + """ + store = modulestore() + try: + return store.get_item(usage_key) + except ItemNotFoundError: + if usage_key.category in CREATE_IF_NOT_FOUND: + # Create a new one for certain categories only. Used for course info handouts. + return store.create_item(user.id, usage_key.course_key, usage_key.block_type, block_id=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) + + +def _get_module_info(xblock, rewrite_static_links=True): """ metadata, data, id representation of a leaf module fetcher. :param usage_key: A UsageKey """ - store = modulestore() - try: - module = store.get_item(usage_key) - except ItemNotFoundError: - if usage_key.category in CREATE_IF_NOT_FOUND: - # 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) - else: - raise - - data = getattr(module, 'data', '') + data = getattr(xblock, 'data', '') if rewrite_static_links: data = replace_static_urls( data, 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. - 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, @@ -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, "release_date": release_date, "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: xblock_info["data"] = data diff --git a/cms/djangoapps/contentstore/views/tests/test_container_page.py b/cms/djangoapps/contentstore/views/tests/test_container_page.py index e3fd69d609..d79464ae09 100644 --- a/cms/djangoapps/contentstore/views/tests/test_container_page.py +++ b/cms/djangoapps/contentstore/views/tests/test_container_page.py @@ -157,39 +157,3 @@ class ContainerPageTestCase(StudioPageTestCase): """ 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) - - 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 = '
' - if has_messages: - self.assertIn(messages_html, html) - else: - self.assertNotIn(messages_html, html) diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index 0f052ea321..d14211da92 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -567,6 +567,55 @@ class TestEditItem(ItemTest): published = self.verify_publish_state(self.problem_usage_key, PublishState.public) 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): """ Helper method to create different draft and published versions of a problem. diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js index 46fccebf73..8cee477c0e 100644 --- a/cms/static/js/models/xblock_info.js +++ b/cms/static/js/models/xblock_info.js @@ -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 * release date status. */ - "locked": null, + "visible_to_staff_only": null, /** * 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 * this will either be the parent subsection or the grandparent section. * 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) { diff --git a/cms/static/js/spec/views/pages/container_spec.js b/cms/static/js/spec/views/pages/container_spec.js index a6e5a3c02c..c1db0bb0e1 100644 --- a/cms/static/js/spec/views/pages/container_spec.js +++ b/cms/static/js/spec/views/pages/container_spec.js @@ -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); containerPage = new ContainerPage(_.extend(options || {}, { model: model, @@ -70,7 +70,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin describe("Initial display", function() { it('can render itself', function() { - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); expect(containerPage.$('.xblock-header').length).toBe(9); expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden'); }); @@ -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() { - renderContainerPage(mockContainerXBlockHtml, this, { + renderContainerPage(this, mockContainerXBlockHtml, { action: 'new' }); expect(containerPage.$('.xblock-header').length).toBe(9); @@ -106,8 +106,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin }; expectEditCanceled = function(test, options) { - var initialRequests, displayNameWrapper; - renderContainerPage(mockContainerXBlockHtml, test); + var initialRequests, displayNameWrapper, displayNameInput; + renderContainerPage(test, mockContainerXBlockHtml); displayNameWrapper = getDisplayNameWrapper(); initialRequests = requests.length; displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, options.newTitle); @@ -125,7 +125,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin it('can edit itself', function() { var editButtons, displayNameElement; - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); displayNameElement = containerPage.$('.page-header-title'); // Click the root edit button @@ -162,7 +162,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin it('can inline edit the display name', function() { var displayNameInput, displayNameWrapper; - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); displayNameWrapper = getDisplayNameWrapper(); displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, updatedDisplayName); displayNameInput.change(); @@ -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() { var initialRequests, displayNameInput, displayNameWrapper; - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); displayNameWrapper = getDisplayNameWrapper(); displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, updatedDisplayName); initialRequests = requests.length; @@ -190,7 +190,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin it('trims whitespace from the display name', function() { var displayNameInput, displayNameWrapper; - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); displayNameWrapper = getDisplayNameWrapper(); displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, updatedDisplayName + ' '); displayNameInput.change(); @@ -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() { var editButtons; - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); editButtons = containerPage.$('.wrapper-xblock .edit-button'); // The container should have rendered six mock xblocks expect(editButtons.length).toBe(6); @@ -258,7 +258,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin it('can save changes to settings', function() { var editButtons, modal, mockUpdatedXBlockHtml; mockUpdatedXBlockHtml = readFixtures('mock/mock-updated-xblock.underscore'); - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); editButtons = containerPage.$('.wrapper-xblock .edit-button'); // The container should have rendered six mock xblocks expect(editButtons.length).toBe(6); @@ -346,24 +346,24 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin }; it("can delete the first xblock", function() { - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); deleteComponentWithSuccess(0); }); it("can delete a middle xblock", function() { - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); deleteComponentWithSuccess(1); }); it("can delete the last xblock", function() { - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); deleteComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1); }); it('does not delete when clicking No in prompt', function () { var numRequests; - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); numRequests = requests.length; // click delete on the first component but press no @@ -378,7 +378,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin it('shows a notification during the delete operation', function() { var notificationSpy = edit_helpers.createNotificationSpy(); - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); clickDelete(0); edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/); create_sinon.respondWithJson(requests, {}); @@ -387,7 +387,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin it('does not delete an xblock upon failure', function () { var notificationSpy = edit_helpers.createNotificationSpy(); - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); clickDelete(0); edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/); create_sinon.respondWithError(requests); @@ -431,23 +431,23 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin }; it("can duplicate the first xblock", function() { - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); duplicateComponentWithSuccess(0); }); it("can duplicate a middle xblock", function() { - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); duplicateComponentWithSuccess(1); }); it("can duplicate the last xblock", function() { - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); duplicateComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1); }); it('shows a notification when duplicating', function () { var notificationSpy = edit_helpers.createNotificationSpy(); - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); clickDuplicate(0); edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/); create_sinon.respondWithJson(requests, {"locator": "new_item"}); @@ -456,7 +456,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin it('does not duplicate an xblock upon failure', function () { var notificationSpy = edit_helpers.createNotificationSpy(); - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); refreshXBlockSpies = spyOn(containerPage, "refreshXBlock"); clickDuplicate(0); edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/); @@ -475,7 +475,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin }; it('sends the correct JSON to the server', function () { - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); clickNewComponent(0); edit_helpers.verifyXBlockRequest(requests, { "category": "discussion", @@ -486,7 +486,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin it('shows a notification while creating', function () { var notificationSpy = edit_helpers.createNotificationSpy(); - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); clickNewComponent(0); edit_helpers.verifyNotificationShowing(notificationSpy, /Adding/); create_sinon.respondWithJson(requests, { }); @@ -495,7 +495,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin it('does not insert component upon failure', function () { var requestCount; - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); clickNewComponent(0); requestCount = requests.length; create_sinon.respondWithError(requests); @@ -514,7 +514,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin verifyCreateHtmlComponent = function(test, templateIndex, expectedRequest) { var xblockCount; - renderContainerPage(mockContainerXBlockHtml, test); + renderContainerPage(test, mockContainerXBlockHtml); showTemplatePicker(); xblockCount = containerPage.$('.studio-xblock-wrapper').length; containerPage.$('.new-component-html a')[templateIndex].click(); diff --git a/cms/static/js/spec/views/pages/container_subviews_spec.js b/cms/static/js/spec/views/pages/container_subviews_spec.js index b7f427787d..6aaaedc6ef 100644 --- a/cms/static/js/spec/views/pages/container_subviews_spec.js +++ b/cms/static/js/spec/views/pages/container_subviews_spec.js @@ -1,11 +1,12 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers", - "js/views/feedback_prompt", "js/views/pages/container", "js/views/pages/container_subviews", - "js/models/xblock_info"], + "js/views/feedback_prompt", "js/views/pages/container", "js/views/pages/container_subviews", + "js/models/xblock_info"], function ($, _, str, create_sinon, edit_helpers, Prompt, ContainerPage, ContainerSubviews, XBlockInfo) { describe("Container Subviews", function() { - var model, containerPage, requests, renderContainerPage, respondWithHtml, respondWithJson, fetch, - disabledCss = "is-disabled", + var model, containerPage, requests, createContainerPage, renderContainerPage, + respondWithHtml, respondWithJson, fetch, + disabledCss = "is-disabled", defaultXBlockInfo, createXBlockInfo, mockContainerPage = readFixtures('mock/mock-container-page.underscore'), mockContainerXBlockHtml = readFixtures('mock/mock-empty-container-xblock.underscore'); @@ -14,27 +15,39 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin edit_helpers.installTemplate('publish-xblock'); edit_helpers.installTemplate('publish-history'); edit_helpers.installTemplate('unit-outline'); + edit_helpers.installTemplate('container-message'); appendSetFixtures(mockContainerPage); + }); - model = new XBlockInfo({ - id: 'locator-container', - display_name: 'Test Container', - category: 'vertical', - published: false, - has_changes: false - }, { - parse: true - }); + defaultXBlockInfo = { + id: 'locator-container', + display_name: 'Test Container', + category: 'vertical', + published: false, + has_changes: false, + edited_on: "Jul 02, 2014 at 14:20 UTC", edited_by: "joe", + 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({ model: model, templates: edit_helpers.mockComponentTemplates, el: $('#content'), isUnitPage: true }); - }); + }; - renderContainerPage = function(html, that) { - requests = create_sinon.requests(that); + renderContainerPage = function (test, html, options) { + createContainerPage(test, options); containerPage.render(); respondWithHtml(html); }; @@ -57,6 +70,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin }; fetch = function (json) { + json = createXBlockInfo(json); model.fetch(); respondWithJson(json); }; @@ -66,30 +80,30 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin previewCss = '.button-preview'; it('renders correctly for private unit', function () { - renderContainerPage(mockContainerXBlockHtml, this); + renderContainerPage(this, mockContainerXBlockHtml); expect(containerPage.$(viewPublishedCss)).toHaveClass(disabledCss); expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss); }); it('updates when published attribute changes', function () { - renderContainerPage(mockContainerXBlockHtml, this); - fetch({"id": "locator-container", "published": true}); + renderContainerPage(this, mockContainerXBlockHtml); + fetch({"published": true}); expect(containerPage.$(viewPublishedCss)).not.toHaveClass(disabledCss); - fetch({"id": "locator-container", "published": false}); + fetch({"published": false}); expect(containerPage.$(viewPublishedCss)).toHaveClass(disabledCss); }); it('updates when has_changes attribute changes', function () { - renderContainerPage(mockContainerXBlockHtml, this); - fetch({"id": "locator-container", "has_changes": true}); + renderContainerPage(this, mockContainerXBlockHtml); + fetch({"has_changes": true}); 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); // 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); }); }); @@ -97,21 +111,21 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin describe("Publisher", function () { var headerCss = '.pub-status', bitPublishingCss = "div.bit-publishing", - publishedBit = "published", - draftBit = "draft", + publishedBit = "is-published", + draftBit = "is-draft", + staffOnlyBit = "is-staff-only", publishButtonCss = ".action-publish", discardChangesButtonCss = ".action-discard", lastDraftCss = ".wrapper-last-draft", releaseDateTitleCss = ".wrapper-release .title", 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. - renderContainerPage(mockContainerXBlockHtml, test); - fetch({"id": "locator-container", "published": true, "has_changes": true}); + containerPage.render(); + respondWithHtml(mockContainerXBlockHtml); + fetch({"published": true, "has_changes": true}); expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass('is-disabled'); expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit); // Click discard changes @@ -132,30 +146,30 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin }); it('renders correctly with private content', function () { - var verifyPrivateState = function(){ - // State is the same regardless of "has_changes" value. - expect(containerPage.$(headerCss).text()).toContain('Draft (Unpublished changes)'); + var verifyPrivateState = function() { + expect(containerPage.$(headerCss).text()).toContain('Draft (Never published)'); expect(containerPage.$(publishButtonCss)).not.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); - fetch({"id": "locator-container", "published": false, "has_changes": false}); + renderContainerPage(this, mockContainerXBlockHtml); + fetch({"published": false, "has_changes": false}); verifyPrivateState(); - fetch({"id": "locator-container", "published": false, "has_changes": true}); + fetch({"published": false, "has_changes": true}); verifyPrivateState(); }); it('renders correctly with public content', function () { - renderContainerPage(mockContainerXBlockHtml, this); - fetch({"id": "locator-container", "published": true, "has_changes": false}); + renderContainerPage(this, mockContainerXBlockHtml); + fetch({"published": true, "has_changes": false}); expect(containerPage.$(headerCss).text()).toContain('Published'); expect(containerPage.$(publishButtonCss)).toHaveClass(disabledCss); expect(containerPage.$(discardChangesButtonCss)).toHaveClass(disabledCss); 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.$(publishButtonCss)).not.toHaveClass(disabledCss); expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass(disabledCss); @@ -164,9 +178,10 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin it('can publish private content', function () { var notificationSpy = edit_helpers.createNotificationSpy(); - renderContainerPage(mockContainerXBlockHtml, this); - fetch({"id": "locator-container", "published": false, "has_changes": false}); - expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit); + renderContainerPage(this, mockContainerXBlockHtml); + fetch({"published": false, "has_changes": false}); + expect(containerPage.$(bitPublishingCss)).not.toHaveClass(draftBit); + expect(containerPage.$(bitPublishingCss)).not.toHaveClass(publishedBit); // Click publish containerPage.$(publishButtonCss).click(); @@ -191,9 +206,9 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin }); it('can does not fetch if publish fails', function () { - renderContainerPage(mockContainerXBlockHtml, this); - fetch({"id": "locator-container", "published": false, "has_changes": false}); - expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit); + renderContainerPage(this, mockContainerXBlockHtml); + fetch({"published": false}); + expect(containerPage.$(bitPublishingCss)).not.toHaveClass(publishedBit); // Click publish containerPage.$(publishButtonCss).click(); @@ -205,17 +220,18 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin expect(requests.length).toEqual(numRequests); // 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. expect(containerPage.model.get("publish")).toBeNull(); }); it('can discard changes', function () { - var notificationSpy = edit_helpers.createNotificationSpy(), - renderPageSpy = spyOn(containerPage.xblockPublisher, 'renderPage').andCallThrough(), - numRequests; + var notificationSpy, renderPageSpy, numRequests; + createContainerPage(this); + notificationSpy = edit_helpers.createNotificationSpy(); + renderPageSpy = spyOn(containerPage.xblockPublisher, 'renderPage').andCallThrough(); - sendDiscardChangesToServer(this); + sendDiscardChangesToServer(); numRequests = requests.length; // Respond with success. @@ -230,10 +246,11 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin }); it('does not fetch if discard changes fails', function () { - var renderPageSpy = spyOn(containerPage.xblockPublisher, 'renderPage').andCallThrough(), - numRequests; + var renderPageSpy, numRequests; + createContainerPage(this); + renderPageSpy = spyOn(containerPage.xblockPublisher, 'renderPage').andCallThrough(); - sendDiscardChangesToServer(this); + sendDiscardChangesToServer(); numRequests = requests.length; // Respond with failure @@ -246,8 +263,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin }); it('does not discard changes on cancel', function () { - renderContainerPage(mockContainerXBlockHtml, this); - fetch({"id": "locator-container", "published": true, "has_changes": true}); + renderContainerPage(this, mockContainerXBlockHtml); + fetch({"published": true, "has_changes": true}); var numRequests = requests.length; // Click discard changes @@ -262,84 +279,213 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin }); it('renders the last published date and user when there are no changes', function () { - renderContainerPage(mockContainerXBlockHtml, this); - fetch({ "id": "locator-container", "has_changes": false, - "edited_on": "Jun 30, 2014 at 14:20 UTC", "edited_by": "joe", - "published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako"}); + renderContainerPage(this, mockContainerXBlockHtml); + fetch({"published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako"}); expect(containerPage.$(lastDraftCss).text()). 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 () { - renderContainerPage(mockContainerXBlockHtml, this); - fetch({ "id": "locator-container", "has_changes": true, - "edited_on": "Jul 02, 2014 at 14:20 UTC", "edited_by": "joe", - "published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako"}); + renderContainerPage(this, mockContainerXBlockHtml); + fetch({"has_changes": true, "edited_on": "Jul 02, 2014 at 14:20 UTC", "edited_by": "joe"}); expect(containerPage.$(lastDraftCss).text()). toContain("Draft saved on Jul 02, 2014 at 14:20 UTC by joe"); }); - it('renders the release date correctly when unreleased', function () { - renderContainerPage(mockContainerXBlockHtml, this); - fetch({ "id": "locator-container", "published": true, "released_to_students": false, - "release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"'}); - expect(containerPage.$(releaseDateTitleCss).text()).toContain("Scheduled:"); - expect(containerPage.$(releaseDateContentCss).text()). - toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"'); + describe("Release Date", function() { + it('renders correctly when unreleased', function () { + 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"'}); + expect(containerPage.$(releaseDateTitleCss).text()).toContain("Scheduled:"); + expect(containerPage.$(releaseDateContentCss).text()). + toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"'); + }); + + it('renders correctly when released', function () { + renderContainerPage(this, mockContainerXBlockHtml); + fetch({"published": true, "released_to_students": true, + "release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"' }); + expect(containerPage.$(releaseDateTitleCss).text()).toContain("Released:"); + expect(containerPage.$(releaseDateContentCss).text()). + toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"'); + }); + + it('renders correctly when the release date is not set', function () { + renderContainerPage(this, mockContainerXBlockHtml); + fetch({"published": true, "released_to_students": false, + "release_date": null, "release_date_from": null }); + expect(containerPage.$(releaseDateTitleCss).text()).toContain("Release:"); + expect(containerPage.$(releaseDateContentCss).text()).toContain("Unscheduled"); + }); + + it('renders correctly when the unit is not published', function () { + renderContainerPage(this, mockContainerXBlockHtml); + fetch({"published": false, "released_to_students": true, + "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 + containerPage.xblockPublisher.render(); + expect(containerPage.$(releaseDateTitleCss).text()).toContain("Release:"); + expect(containerPage.$(releaseDateContentCss).text()). + toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"'); + }); }); - it('renders the release date correctly when released', function () { - renderContainerPage(mockContainerXBlockHtml, this); - fetch({ "id": "locator-container", "published": true, "released_to_students": true, - "release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"' }); - expect(containerPage.$(releaseDateTitleCss).text()).toContain("Released:"); - expect(containerPage.$(releaseDateContentCss).text()). - toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"'); - }); + describe("Content Visibility", function () { + var requestStaffOnly, verifyStaffOnly, promptSpy; - it('renders the release date correctly when the release date is not set', function () { - renderContainerPage(mockContainerXBlockHtml, this); - fetch({ "id": "locator-container", "published": true, "released_to_students": false, - "release_date": null, "release_date_from": null }); - expect(containerPage.$(releaseDateTitleCss).text()).toContain("Release:"); - expect(containerPage.$(releaseDateContentCss).text()).toContain("Unscheduled"); - }); + requestStaffOnly = function(isStaffOnly) { + containerPage.$('.action-staff-lock').click(); - it('renders the release date correctly when the unit is not published', function () { - renderContainerPage(mockContainerXBlockHtml, this); - fetch({ "id": "locator-container", "published": false, "released_to_students": true, - "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 - containerPage.xblockPublisher.render(); - expect(containerPage.$(releaseDateTitleCss).text()).toContain("Release:"); - expect(containerPage.$(releaseDateContentCss).text()). - toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"'); + // 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 () { var lastPublishCss = ".wrapper-last-publish"; - it('renders the last published date and user when the block is published', function () { - renderContainerPage(mockContainerXBlockHtml, this); - fetch({ "id": "locator-container", "published": true, - "published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako" }); + it('renders the last published date and user when the block is published', function() { + renderContainerPage(this, mockContainerXBlockHtml); + fetch({ + "published": true, "published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako" + }); expect(containerPage.$(lastPublishCss).text()). toContain("Last published Jul 01, 2014 at 12:45 UTC by amako"); }); it('renders never published when the block is unpublished', function () { - renderContainerPage(mockContainerXBlockHtml, this); - fetch({ "id": "locator-container", "published": false, - "published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako" }); + renderContainerPage(this, mockContainerXBlockHtml); + fetch({ "published": false }); expect(containerPage.$(lastPublishCss).text()).toContain("Never published"); }); it('renders correctly when the block is published without publish info', function () { - renderContainerPage(mockContainerXBlockHtml, this); - fetch({ "id": "locator-container", "published": true, "published_on": null, "published_by": null}); + renderContainerPage(this, mockContainerXBlockHtml); + fetch({ + "published": true, "published_on": null, "published_by": null + }); 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); + }); + }); }); }); diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 5d43ff6ed0..25806b1fb1 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -29,6 +29,11 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views model: this.model, view: this.view }); + this.messageView = new ContainerSubviews.MessageView({ + el: this.$('.container-message'), + model: this.model + }); + this.messageView.render(); this.isUnitPage = this.options.isUnitPage; if (this.isUnitPage) { this.xblockPublisher = new ContainerSubviews.Publisher({ diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js index 2ae82ccaaa..7bf27078a8 100644 --- a/cms/static/js/views/pages/container_subviews.js +++ b/cms/static/js/views/pages/container_subviews.js @@ -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 * after a server sync operation. */ - var UnitStateListenerView = BaseView.extend({ + var ContainerStateListenerView = BaseView.extend({ // takes XBlockInfo as a model initialize: function() { @@ -18,18 +18,43 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ }, onSync: function(model) { - if (ViewUtils.hasChangedAttributes(model, ['has_changes', 'published'])) { + if (this.shouldRefresh(model)) { this.render(); } }, + shouldRefresh: function(model) { + return false; + }, + 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. */ - var PreviewActionController = UnitStateListenerView.extend({ + var PreviewActionController = ContainerStateListenerView.extend({ + shouldRefresh: function(model) { + return ViewUtils.hasChangedAttributes(model, ['has_changes', 'published']); + }, render: function() { var previewAction = this.$el.find('.button-preview'), @@ -59,7 +84,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ var Publisher = BaseView.extend({ events: { 'click .action-publish': 'publish', - 'click .action-discard': 'discardChanges' + 'click .action-discard': 'discardChanges', + 'click .action-staff-lock': 'toggleStaffLock' }, // takes XBlockInfo as a model @@ -72,22 +98,25 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ }, 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(); } }, render: function () { this.$el.html(this.template({ - has_changes: this.model.get('has_changes'), + hasChanges: this.model.get('has_changes'), published: this.model.get('published'), - edited_on: this.model.get('edited_on'), - edited_by: this.model.get('edited_by'), - published_on: this.model.get('published_on'), - published_by: this.model.get('published_by'), - released_to_students: this.model.get('released_to_students'), - release_date: this.model.get('release_date'), - release_date_from: this.model.get('release_date_from') + editedOn: this.model.get('edited_on'), + editedBy: this.model.get('edited_by'), + publishedOn: this.model.get('published_on'), + publishedBy: this.model.get('published_by'), + releasedToStudents: this.model.get('released_to_students'), + releaseDate: this.model.get('release_date'), + releaseDateFrom: this.model.get('release_date_from'), + visibleToStaffOnly: this.model.get('visible_to_staff_only') })); return this; @@ -127,10 +156,60 @@ 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…'), + _.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…'), + _.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. */ @@ -161,6 +240,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ }); return { + 'MessageView': MessageView, 'PreviewActionController': PreviewActionController, 'Publisher': Publisher, 'PublishHistory': PublishHistory diff --git a/cms/static/js/views/utils/view_utils.js b/cms/static/js/views/utils/view_utils.js index 52007c3628..f4f44126a8 100644 --- a/cms/static/js/views/utils/view_utils.js +++ b/cms/static/js/views/utils/view_utils.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. */ - confirmThenRunOperation = function(title, message, actionLabel, operation) { + confirmThenRunOperation = function(title, message, actionLabel, operation, onCancelCallback) { return new PromptView.Warning({ title: title, message: message, @@ -48,6 +48,9 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js secondary: { text: gettext('Cancel'), click: function(prompt) { + if (onCancelCallback) { + onCancelCallback(); + } return prompt.hide(); } } diff --git a/cms/static/sass/_developer.scss b/cms/static/sass/_developer.scss index dd92d653d4..30859b4094 100644 --- a/cms/static/sass/_developer.scss +++ b/cms/static/sass/_developer.scss @@ -11,8 +11,7 @@ //.wrapper-xblock-header { -.view-outline, -.view-container { +.view-outline { .add-xblock-component { text-align: center; diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss index 2a015c3f23..a6957810a8 100644 --- a/cms/static/sass/views/_container.scss +++ b/cms/static/sass/views/_container.scss @@ -111,11 +111,15 @@ &.staff-only, &.is-staff-only { @extend %bar-module-black; + + &.is-scheduled .wrapper-release .copy { + text-decoration: line-through; + } } .bar-mod-content { border: 0; - padding: ($baseline/2) ($baseline*.75) ($baseline*.75) ($baseline*.75); + padding: ($baseline/2) ($baseline*.75) ($baseline/4) ($baseline*.75); .title { margin-bottom: ($baseline/10); @@ -123,7 +127,6 @@ } .wrapper-last-draft { - padding: ($baseline*.75) ($baseline*.75) ($baseline/4) ($baseline*.75); .date, .user { @@ -145,9 +148,9 @@ font-weight: 600; } - .action-inline [class^="icon-"] { - margin: 0 ($baseline/4); - + [class^="icon-"] { + margin-left: ($baseline/4); + color: $gray-d1; } } @@ -215,107 +218,7 @@ } .wrapper-unit-tree-location { - - .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; - } - } + // tree location-specific styles should go here } } } diff --git a/cms/templates/container.html b/cms/templates/container.html index 6cd6391a13..bf611c0a27 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -25,7 +25,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal", "editor-mode-button", "upload-dialog", "image-modal", "add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu", "add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history", - "unit-outline"] + "unit-outline", "container-message"] %> <%block name="header_extras"> % for template_name in templates: @@ -116,16 +116,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
- % if is_visible_to_students: -
-
-

- - ${_("This content is live for students. Edit with caution.")} -

-
-
- % endif +
diff --git a/cms/templates/js/container-message.underscore b/cms/templates/js/container-message.underscore new file mode 100644 index 0000000000..b47fa39578 --- /dev/null +++ b/cms/templates/js/container-message.underscore @@ -0,0 +1,8 @@ +<% if (currentlyVisibleToStudents) { %> +
+

+ + <%= gettext("This content is live for students. Edit with caution.") %> +

+
+<% } %> diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore index 1034cf2860..a9d52cfddb 100644 --- a/cms/templates/js/course-outline.underscore +++ b/cms/templates/js/course-outline.underscore @@ -14,8 +14,8 @@ <% if (xblockInfo.get('category') === 'vertical') { %> <%= xblockInfo.get('display_name') %> <% } else { %> - "> - <%= xblockInfo.get('display_name') %> + "> + <%= xblockInfo.get('display_name') %> <% } %> diff --git a/cms/templates/js/mock/mock-container-page.underscore b/cms/templates/js/mock/mock-container-page.underscore index 87a36330a8..2c39ab5081 100644 --- a/cms/templates/js/mock/mock-container-page.underscore +++ b/cms/templates/js/mock/mock-container-page.underscore @@ -43,6 +43,7 @@
+