From d65e887d1ab3f91d17b512031ad33d0c2be8954b Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Thu, 3 Jul 2014 10:44:35 -0400 Subject: [PATCH] New Publishing controls on Unit page. STUD-1707 --- cms/djangoapps/contentstore/features/video.py | 1 + .../contentstore/views/component.py | 39 ++- cms/djangoapps/contentstore/views/item.py | 66 +++-- .../contentstore/views/tests/test_item.py | 139 +-------- cms/static/coffee/spec/main.coffee | 1 + cms/static/js/models/xblock_info.js | 44 ++- .../js/spec/views/pages/container_spec.js | 15 +- .../views/pages/container_subviews_spec.js | 268 ++++++++++++++++++ cms/static/js/views/container.js | 4 +- cms/static/js/views/pages/container.js | 44 ++- .../js/views/pages/container_subviews.js | 167 +++++++++++ .../js/views/xblock_string_field_editor.js | 3 +- cms/templates/container.html | 56 ++-- .../js/mock/mock-container-page.underscore | 62 +++- cms/templates/js/publish-xblock.underscore | 51 ++++ cms/templates/studio_xblock_wrapper.html | 2 +- .../lib/xmodule/xmodule/modulestore/mixed.py | 2 +- .../tests/test_mixed_modulestore.py | 24 ++ .../studio_render_children_view.html | 2 +- 19 files changed, 773 insertions(+), 217 deletions(-) create mode 100644 cms/static/js/spec/views/pages/container_subviews_spec.js create mode 100644 cms/static/js/views/pages/container_subviews.js create mode 100644 cms/templates/js/publish-xblock.underscore diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index a66f28d536..083193760d 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -152,6 +152,7 @@ def xml_only_video(step): category='video', data='' % youtube_id, modulestore=store, + user_id=world.scenario_dict["USER"].id ) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 5e7f621eb3..b6ccfce2bb 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -23,6 +23,7 @@ from xblock.runtime import Mixologist 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 from models.settings.course_grading import CourseGradingModel from opaque_keys.edx.keys import UsageKey @@ -148,7 +149,7 @@ def container_handler(request, usage_key_string): usage_key = UsageKey.from_string(usage_key_string) try: - course, xblock, __ = _get_item_in_course(request, usage_key) + course, xblock, lms_link = _get_item_in_course(request, usage_key) except ItemNotFoundError: return HttpResponseBadRequest() @@ -166,15 +167,38 @@ def container_handler(request, usage_key_string): parent = get_parent_xblock(parent) ancestor_xblocks.reverse() - subsection = get_parent_xblock(unit) if unit else None - section = get_parent_xblock(subsection) if subsection else None - # TODO: correct with publishing story. - unit_publish_state = 'draft' + assert unit is not None, "Could not determine unit page" + subsection = get_parent_xblock(unit) + assert subsection is not None, "Could not determine parent subsection from unit " + unicode(unit.location) + section = get_parent_xblock(subsection) + assert section is not None, "Could not determine ancestor section from unit " + unicode(unit.location) + xblock_info = create_xblock_info(usage_key, xblock) + + # Create the link for preview. + preview_lms_base = settings.FEATURES.get('PREVIEW_LMS_BASE') + # need to figure out where this item is in the list of children as the + # preview will need this + index = 1 + for child in subsection.get_children(): + if child.location == unit.location: + break + index += 1 + preview_lms_link = ( + u'//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}' + ).format( + preview_lms_base=preview_lms_base, + lms_base=settings.LMS_BASE, + org=course.location.org, + course=course.location.course, + course_name=course.location.name, + section=section.location.name, + subsection=subsection.location.name, + index=index + ) return render_to_response('container.html', { 'context_course': course, # Needed only for display of menus at top of page. 'xblock': xblock, - 'unit_publish_state': unit_publish_state, 'xblock_locator': xblock.location, 'unit': unit, 'is_unit_page': is_unit_page, @@ -183,6 +207,9 @@ def container_handler(request, usage_key_string): 'new_unit_category': 'vertical', 'ancestor_xblocks': ancestor_xblocks, 'component_templates': json.dumps(component_templates), + 'xblock_info': xblock_info, + 'draft_preview_link': preview_lms_link, + 'published_preview_link': lms_link, }) else: return HttpResponseBadRequest("Only supports HTML requests") diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index cca0c8e0fe..94ac3fcbef 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -23,9 +23,13 @@ import xmodule from xmodule.tabs import StaticTab, CourseTabList from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore -from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError, DuplicateItemError +from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError from xmodule.modulestore.inheritance import own_metadata from xmodule.x_module import PREVIEW_VIEWS, STUDIO_VIEW, STUDENT_VIEW +from contentstore.utils import compute_publish_state +from xmodule.modulestore import PublishState +from django.contrib.auth.models import User +from util.date_utils import get_default_time_display from util.json_request import expect_json, JsonResponse @@ -92,7 +96,7 @@ 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 one of three values, 'make_public, 'make_private', or 'create_draft' + :publish: can be only one value-- 'make_public' 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 @@ -183,7 +187,6 @@ def xblock_view_handler(request, usage_key_string, view_name): if 'application/json' in accept_header: store = modulestore() xblock = store.get_item(usage_key) - is_read_only = _is_xblock_read_only(xblock) container_views = ['container_preview', 'reorderable_container_child_preview'] # wrap the generated fragment in the xmodule_editor div so that the javascript @@ -216,7 +219,6 @@ def xblock_view_handler(request, usage_key_string, view_name): context = { 'is_pages_view': is_pages_view, # This setting disables the recursive wrapping of xblocks 'is_unit_page': is_unit(xblock), - 'read_only': is_read_only, 'root_xblock': xblock if (view_name == 'container_preview') else None, 'reorderable_items': reorderable_items } @@ -249,19 +251,6 @@ def xblock_view_handler(request, usage_key_string, view_name): return HttpResponse(status=406) -def _is_xblock_read_only(xblock): - """ - Returns true if the specified xblock is read-only, meaning that it cannot be edited. - """ - # We allow direct editing of xblocks in DIRECT_ONLY_CATEGORIES (for example, static pages). - # if xblock.category in DIRECT_ONLY_CATEGORIES: - # return False - # component_publish_state = compute_publish_state(xblock) - # return component_publish_state == PublishState.public - # TODO: correct with publishing story. - return False - - def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout=None, grader_type=None, publish=None): """ @@ -287,19 +276,6 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout old_metadata = own_metadata(existing_item) old_content = existing_item.get_explicitly_set_fields_by_scope(Scope.content) - if publish: - if publish == 'make_private': - try: - store.unpublish(existing_item.location, user.id), - except ItemNotFoundError: - pass - elif publish == 'create_draft': - try: - store.convert_to_draft(existing_item.location, user.id) - except DuplicateItemError: - pass - - if data: # TODO Allow any scope.content fields not just "data" (exactly like the get below this) existing_item.data = data @@ -555,8 +531,30 @@ def _get_module_info(usage_key, user, rewrite_static_links=True): ) # Note that children aren't being returned until we have a use case. - return { - 'id': unicode(module.location), - 'data': data, - 'metadata': own_metadata(module) + return create_xblock_info(usage_key, module, data, own_metadata(module)) + + +def create_xblock_info(usage_key, xblock, data=None, metadata=None): + """ + Creates the information needed for client-side XBlockInfo. + + If data or metadata are not specified, their information will not be added + (regardless of whether or not the xblock actually has data or metadata). + """ + publish_state = compute_publish_state(xblock) if xblock else None + + xblock_info = { + "id": unicode(xblock.location), + "display_name": xblock.display_name_with_default, + "category": xblock.category, + "has_changes": modulestore().has_changes(usage_key), + "published": publish_state in (PublishState.public, PublishState.draft), + "edited_on": get_default_time_display(xblock.edited_on) if xblock.edited_on else None, + "edited_by": User.objects.get(id=xblock.edited_by).username if xblock.edited_by else None } + if data is not None: + xblock_info["data"] = data + if metadata is not None: + xblock_info["metadata"] = metadata + + return xblock_info diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index e7d476ca57..2cbcca7f93 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -549,22 +549,6 @@ class TestEditItem(ItemTest): ) self.verify_publish_state(self.problem_usage_key, PublishState.public) - def test_make_private(self): - """ Test making a public problem private (un-publishing it). """ - # Make problem public. - self.client.ajax_post( - self.problem_update_url, - data={'publish': 'make_public'} - ) - self.verify_publish_state(self.problem_usage_key, PublishState.public) - - # Now make it private - self.client.ajax_post( - self.problem_update_url, - data={'publish': 'make_private'} - ) - self.verify_publish_state(self.problem_usage_key, PublishState.private) - def test_make_draft(self): """ Test creating a draft version of a public problem. """ # Make problem public. @@ -574,13 +558,6 @@ class TestEditItem(ItemTest): ) published = self.verify_publish_state(self.problem_usage_key, PublishState.public) - # Now make it draft, which means both versions will exist. - self.client.ajax_post( - self.problem_update_url, - data={'publish': 'create_draft'} - ) - self.verify_publish_state(self.problem_usage_key, PublishState.draft) - # Update the draft version and check that published is different. self.client.ajax_post( self.problem_update_url, @@ -589,6 +566,9 @@ class TestEditItem(ItemTest): updated_draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True) self.assertEqual(updated_draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) self.assertIsNone(published.due) + # Fetch the published version again to make sure the due date is still unset. + published = modulestore().get_item(published.location, revision=REVISION_OPTION_PUBLISHED_ONLY) + self.assertIsNone(published.due) def test_make_public_with_update(self): """ Update a problem and make it public at the same time. """ @@ -602,112 +582,6 @@ class TestEditItem(ItemTest): published = self.get_item_from_modulestore(self.problem_usage_key) self.assertEqual(published.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) - def test_make_private_with_update(self): - """ Make a problem private and update it at the same time. """ - # Make problem public. - self.client.ajax_post( - self.problem_update_url, - data={'publish': 'make_public'} - ) - self.verify_publish_state(self.problem_usage_key, PublishState.public) - - # Make problem private and update. - self.client.ajax_post( - self.problem_update_url, - data={ - 'metadata': {'due': '2077-10-10T04:00Z'}, - 'publish': 'make_private' - } - ) - draft = self.verify_publish_state(self.problem_usage_key, PublishState.private) - self.assertEqual(draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) - - def test_create_draft_with_update(self): - """ Create a draft and update it at the same time. """ - # Make problem public. - self.client.ajax_post( - self.problem_update_url, - data={'publish': 'make_public'} - ) - published = self.verify_publish_state(self.problem_usage_key, PublishState.public) - - # Now make it draft, which means both versions will exist. - self.client.ajax_post( - self.problem_update_url, - data={ - 'metadata': {'due': '2077-10-10T04:00Z'}, - 'publish': 'create_draft' - } - ) - draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True) - self.assertEqual(draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) - self.assertIsNone(published.due) - - def test_create_draft_with_multiple_requests(self): - """ - Create a draft request returns already created version if it exists. - """ - # Make problem public. - self.client.ajax_post( - self.problem_update_url, - data={'publish': 'make_public'} - ) - self.verify_publish_state(self.problem_usage_key, PublishState.public) - - # Now make it draft, which means both versions will exist. - self.client.ajax_post( - self.problem_update_url, - data={ - 'publish': 'create_draft' - } - ) - draft_1 = self.verify_publish_state(self.problem_usage_key, PublishState.draft) - - # Now check that when a user sends request to create a draft when there is already a draft version then - # user gets that already created draft instead of getting 'DuplicateItemError' exception. - self.client.ajax_post( - self.problem_update_url, - data={ - 'publish': 'create_draft' - } - ) - draft_2 = self.verify_publish_state(self.problem_usage_key, PublishState.draft) - self.assertIsNotNone(draft_2) - self.assertEqual(draft_1, draft_2) - - def test_make_private_with_multiple_requests(self): - """ - Make private requests gets proper response even if xmodule is already made private. - """ - # Make problem public. - self.client.ajax_post( - self.problem_update_url, - data={'publish': 'make_public'} - ) - self.assertIsNotNone(self.get_item_from_modulestore(self.problem_usage_key)) - - # Now make it private, and check that its version is private - resp = self.client.ajax_post( - self.problem_update_url, - data={ - 'publish': 'make_private' - } - ) - self.assertEqual(resp.status_code, 200) - draft_1 = self.verify_publish_state(self.problem_usage_key, PublishState.private) - - # Now check that when a user sends request to make it private when it already is private then - # user gets that private version instead of getting 'ItemNotFoundError' exception. - self.client.ajax_post( - self.problem_update_url, - data={ - 'publish': 'make_private' - } - ) - self.assertEqual(resp.status_code, 200) - draft_2 = self.verify_publish_state(self.problem_usage_key, PublishState.private) - self.assertEqual(draft_1, draft_2) - def test_published_and_draft_contents_with_update(self): """ Create a draft and publish it then modify the draft and check that published content is not modified """ @@ -724,8 +598,7 @@ class TestEditItem(ItemTest): data={ 'id': unicode(self.problem_usage_key), 'metadata': {}, - 'data': "

Problem content draft.

", - 'publish': 'create_draft' + 'data': "

Problem content draft.

" } ) @@ -746,6 +619,9 @@ class TestEditItem(ItemTest): # Both published and draft content should still be different draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True) self.assertNotEqual(draft.data, published.data) + # Fetch the published version again to make sure the data is correct. + published = modulestore().get_item(published.location, revision=REVISION_OPTION_PUBLISHED_ONLY) + self.assertNotEqual(draft.data, published.data) def test_publish_states_of_nested_xblocks(self): """ Test publishing of a unit page containing a nested xblock """ @@ -777,7 +653,6 @@ class TestEditItem(ItemTest): data={ 'id': unicode(unit_usage_key), 'metadata': {}, - 'publish': 'create_draft' } ) self.assertEqual(resp.status_code, 200) diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index 50935e2924..38d4444059 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -229,6 +229,7 @@ define([ "js/spec/views/xblock_editor_spec", "js/spec/views/pages/container_spec", + "js/spec/views/pages/container_subviews_spec", "js/spec/views/pages/group_configurations_spec", "js/spec/views/modals/base_modal_spec", diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js index aa16604654..0bb6cc3df9 100644 --- a/cms/static/js/models/xblock_info.js +++ b/cms/static/js/models/xblock_info.js @@ -7,12 +7,52 @@ define(["backbone", "js/utils/module"], function(Backbone, ModuleUtils) { "id": null, "display_name": null, "category": null, - "is_draft": null, "is_container": null, "data": null, "metadata" : null, - "children": null + "children": null, + /** + * True iff: + * 1) Edits have been made to the xblock and no published version exists. + * 2) Edits have been made to the xblock since the last published version. + */ + "has_changes": null, + /** + * True iff a published version of the xblock exists with a release date in the past, + * and the xblock is not locked. + */ + "released_to_students": null, + /** + * True iff a published version of the xblock exists. + */ + "published": null, + /** + * If true, only course staff can see the xblock regardless of publish status or + * release date status. + */ + "locked": null, + /** + * Date of last edit to this xblock. Will be the latest change to either the draft + * or the published version. + */ + "edited_on":null, + /** + * User who last edited the xblock. + */ + "edited_by":null, + /** + * If the xblock is published, the date on which it will be released to students. + */ + "release_date": null, + /** + * The xblock which is determining the release date. For instance, for a unit, + * this will either be the parent subsection or the grandparent section. + */ + "release_date_from":null } + // NOTE: 'publish' is not an attribute on XBlockInfo, but it used to signal the publish + // and discard changes actions. Therefore 'publish' cannot be introduced as an attribute. + }); return XBlockInfo; }); diff --git a/cms/static/js/spec/views/pages/container_spec.js b/cms/static/js/spec/views/pages/container_spec.js index a5a7796769..b053a3da9d 100644 --- a/cms/static/js/spec/views/pages/container_spec.js +++ b/cms/static/js/spec/views/pages/container_spec.js @@ -143,7 +143,10 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin renderContainerPage(mockContainerXBlockHtml, this); inlineEditDisplayName(updatedDisplayName); displayNameInput.change(); + // This is the response for the change operation. create_sinon.respondWithJson(requests, { }); + // This is the response for the subsequent fetch operation. + create_sinon.respondWithJson(requests, {"display_name": updatedDisplayName}); expect(displayNameInput).toHaveClass('is-hidden'); expect(displayNameElement).not.toHaveClass('is-hidden'); expect(displayNameElement.text().trim()).toBe(updatedDisplayName); @@ -153,8 +156,11 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin it('does not change the title when a display name update fails', function() { renderContainerPage(mockContainerXBlockHtml, this); inlineEditDisplayName(updatedDisplayName); + var initialRequests = requests.length; displayNameInput.change(); create_sinon.respondWithError(requests); + // No fetch operation should occur. + expect(initialRequests + 1).toBe(requests.length); expect(displayNameElement).toHaveClass('is-hidden'); expect(displayNameInput).not.toHaveClass('is-hidden'); expect(displayNameInput.val().trim()).toBe(updatedDisplayName); @@ -305,14 +311,19 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin create_sinon.respondWithJson(requests, {}); // first request contains given component's id (to delete the component) - expect(requests[requests.length - 2].url).toMatch( + expect(requests[requests.length - 3].url).toMatch( new RegExp("locator-component-" + GROUP_TO_TEST + (componentIndex + 1)) ); // second request contains parent's id (to remove as child) - expect(lastRequest().url).toMatch( + expect(requests[requests.length - 2].url).toMatch( new RegExp("locator-group-" + GROUP_TO_TEST) ); + + // third request if a fetch of the container. + expect(lastRequest().url).toMatch( + new RegExp("locator-container") + ); }; deleteComponentWithSuccess = function(componentIndex) { diff --git a/cms/static/js/spec/views/pages/container_subviews_spec.js b/cms/static/js/spec/views/pages/container_subviews_spec.js new file mode 100644 index 0000000000..ecfde1559f --- /dev/null +++ b/cms/static/js/spec/views/pages/container_subviews_spec.js @@ -0,0 +1,268 @@ +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"], + 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", + mockContainerPage = readFixtures('mock/mock-container-page.underscore'), + mockContainerXBlockHtml = readFixtures('mock/mock-empty-container-xblock.underscore'); + + beforeEach(function () { + edit_helpers.installTemplate('xblock-string-field-editor'); + edit_helpers.installTemplate('publish-xblock'); + appendSetFixtures(mockContainerPage); + + model = new XBlockInfo({ + id: 'locator-container', + display_name: 'Test Container', + category: 'vertical', + published: false, + has_changes: false + }); + containerPage = new ContainerPage({ + model: model, + templates: edit_helpers.mockComponentTemplates, + el: $('#content'), + isUnitPage: true + }); + }); + + renderContainerPage = function(html, that) { + requests = create_sinon.requests(that); + containerPage.render(); + respondWithHtml(html); + }; + + respondWithHtml = function(html) { + var requestIndex = requests.length - 1; + create_sinon.respondWithJson( + requests, + { html: html, "resources": [] }, + requestIndex + ); + }; + + respondWithJson = function(json) { + var requestIndex = requests.length - 1; + create_sinon.respondWithJson( + requests, + json, + requestIndex + ); + }; + + fetch = function (json) { + model.fetch(); + respondWithJson(json); + }; + + describe("PreviewActionController", function () { + var viewPublishedCss = '.view-button', + previewCss = '.preview-button'; + + it('renders correctly for private unit', function () { + renderContainerPage(mockContainerXBlockHtml, this); + 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}); + expect(containerPage.$(viewPublishedCss)).not.toHaveClass(disabledCss); + + fetch({"id": "locator-container", "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}); + expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss); + + fetch({"id": "locator-container", "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}); + expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss); + }); + }); + + describe("VisibilityStateController", function () { + var unitVisibilityCss = '.section-item.editing a'; + + it('renders initially as private with unpublished content', function () { + renderContainerPage(mockContainerXBlockHtml, this); + expect(containerPage.$(unitVisibilityCss)).toHaveClass('private-item'); + }); + + it('renders as public when published and no changes', function () { + renderContainerPage(mockContainerXBlockHtml, this); + fetch({"id": "locator-container", "published": true, "has_changes": false}); + expect(containerPage.$(unitVisibilityCss)).toHaveClass('public-item'); + }); + + it('renders as draft when published and changes', function () { + renderContainerPage(mockContainerXBlockHtml, this); + fetch({"id": "locator-container", "published": true, "has_changes": true}); + expect(containerPage.$(unitVisibilityCss)).toHaveClass('draft-item'); + }); + + it('renders as private when not published', function () { + renderContainerPage(mockContainerXBlockHtml, this); + fetch({"id": "locator-container", "published": false, "has_changes": true}); + expect(containerPage.$(unitVisibilityCss)).toHaveClass('private-item'); + + fetch({"id": "locator-container", "published": false, "has_changes": false}); + expect(containerPage.$(unitVisibilityCss)).toHaveClass('private-item'); + + fetch({"id": "locator-container", "published": false}); + expect(containerPage.$(unitVisibilityCss)).toHaveClass('private-item'); + }); + }); + + describe("Publisher", function () { + var headerCss = '.pub-status', + bitPublishingCss = "div.bit-publishing", + publishedBit = "published", + draftBit = "draft", + publishButtonCss = ".action-publish", + discardChangesButtonCss = ".action-discard", + request, lastRequest, promptSpies; + + lastRequest = function() { return requests[requests.length - 1]; }; + + beforeEach(function() { + promptSpies = spyOnConstructor(Prompt, "Warning", ["show", "hide"]); + promptSpies.show.andReturn(this.promptSpies); + }); + + 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)'); + expect(containerPage.$(publishButtonCss)).not.toHaveClass(disabledCss); + expect(containerPage.$(discardChangesButtonCss)).toHaveClass(disabledCss); + expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit); + }; + renderContainerPage(mockContainerXBlockHtml, this); + fetch({"id": "locator-container", "published": false, "has_changes": false}); + verifyPrivateState(); + + fetch({"id": "locator-container", "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}); + 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}); + expect(containerPage.$(headerCss).text()).toContain('Draft (Unpublished changes)'); + expect(containerPage.$(publishButtonCss)).not.toHaveClass(disabledCss); + expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass(disabledCss); + expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit); + }); + + 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); + + // Click publish + containerPage.$(publishButtonCss).click(); + edit_helpers.verifyNotificationShowing(notificationSpy, /Publishing/); + + request = lastRequest(); + expect(request.url).toEqual("/xblock/locator-container"); + expect(request.method).toEqual("POST"); + expect(JSON.parse(request.requestBody).publish).toEqual("make_public"); + + // Response to publish call + respondWithJson({"id": "locator-container", "published": true, "has_changes": false}); + edit_helpers.verifyNotificationHidden(notificationSpy); + + request = lastRequest(); + expect(request.url).toEqual("/xblock/locator-container"); + expect(request.method).toEqual("GET"); + expect(request.requestBody).toEqual(null); + // Response to fetch + respondWithJson({"id": "locator-container", "published": true, "has_changes": false}); + + // Verify updates displayed + expect(containerPage.$(bitPublishingCss)).toHaveClass(publishedBit); + }); + + 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); + + // Click publish + containerPage.$(publishButtonCss).click(); + + var numRequests = requests.length; + // Respond with failure + create_sinon.respondWithError(requests); + + expect(requests.length).toEqual(numRequests); + + // Verify still in draft state. + expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit); + }); + + it('can discard changes', function () { + renderContainerPage(mockContainerXBlockHtml, this); + fetch({"id": "locator-container", "published": true, "has_changes": true}); + expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass('is-disabled'); + expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit); + // Click discard changes + containerPage.$(discardChangesButtonCss).click(); + + // Confirm the discard. + expect(promptSpies.constructor).toHaveBeenCalled(); + promptSpies.constructor.mostRecentCall.args[0].actions.primary.click(promptSpies); + + request = lastRequest(); + expect(request.url).toEqual("/xblock/locator-container"); + expect(request.method).toEqual("DELETE"); + expect(request.requestBody).toEqual(null); + + // Respond with failure because code does window.location.reload (which will + // put tests into an infinite loop) on success. + var numRequests = requests.length; + // Respond with failure + create_sinon.respondWithError(requests); + + expect(requests.length).toEqual(numRequests); + expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass('is-disabled'); + }); + + it('does not discard changes on cancel', function () { + renderContainerPage(mockContainerXBlockHtml, this); + fetch({"id": "locator-container", "published": true, "has_changes": true}); + expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass('is-disabled'); + expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit); + var numRequests = requests.length; + + // Click discard changes + containerPage.$(discardChangesButtonCss).click(); + + // Click cancel to confirmation. + expect(promptSpies.constructor).toHaveBeenCalled(); + promptSpies.constructor.mostRecentCall.args[0].actions.secondary.click(promptSpies); + + expect(requests.length).toEqual(numRequests); + expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass('is-disabled'); + }); + }); + }); + }); diff --git a/cms/static/js/views/container.js b/cms/static/js/views/container.js index 7a979a4496..a49b9660d4 100644 --- a/cms/static/js/views/container.js +++ b/cms/static/js/views/container.js @@ -82,7 +82,7 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", }, updateChildren: function (targetParent, successCallback) { - var children, childLocators; + var children, childLocators, xblockInfo=this.model; // Find descendants with class "studio-xblock-wrapper" whose parent === targetParent. // This is necessary to filter our grandchildren, great-grandchildren, etc. @@ -110,6 +110,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", if (successCallback) { successCallback(); } + // Update publish and last modified information from the server. + xblockInfo.fetch(); } }); }, diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index fc79e69c3a..af437f2d13 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -4,15 +4,15 @@ */ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/container", "js/views/xblock", "js/views/components/add_xblock", "js/views/modals/edit_xblock", "js/models/xblock_info", - "js/views/xblock_string_field_editor"], + "js/views/xblock_string_field_editor", "js/views/pages/container_subviews"], function ($, _, gettext, BaseView, ContainerView, XBlockView, AddXBlockComponent, EditXBlockModal, XBlockInfo, - XBlockStringFieldEditor) { + XBlockStringFieldEditor, ContainerSubviews) { var XBlockContainerPage = BaseView.extend({ // takes XBlockInfo as a model view: 'container_preview', - initialize: function() { + initialize: function(options) { BaseView.prototype.initialize.call(this); this.nameEditor = new XBlockStringFieldEditor({ el: this.$('.wrapper-xblock-field'), @@ -24,16 +24,39 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai model: this.model, view: this.view }); + this.isUnitPage = this.options.isUnitPage; + if (this.isUnitPage) { + this.xblockPublisher = new ContainerSubviews.Publisher({ + el: this.$('#publish-unit'), + model: this.model + }); + this.xblockPublisher.render(); + + // No need to render initially. This is only used for updating state + // when the unit changes visibility. + this.visibilityState = new ContainerSubviews.VisibilityStateController({ + el: this.$('.section-item.editing a'), + model: this.model + }); + this.previewActions = new ContainerSubviews.PreviewActionController({ + el: this.$('.nav-actions'), + model: this.model + }); + this.previewActions.render(); + } }, render: function(options) { var self = this, xblockView = this.xblockView, - loadingElement = this.$('.ui-loading'); - loadingElement.removeClass('is-hidden'); + loadingElement = this.$('.ui-loading'), + unitLocationTree = this.$('.unit-location'), + hiddenCss='is-hidden'; + + loadingElement.removeClass(hiddenCss); // Hide both blocks until we know which one to show - xblockView.$el.addClass('is-hidden'); + xblockView.$el.addClass(hiddenCss); if (!options || !options.refresh) { // Add actions to any top level buttons, e.g. "Edit" of the container itself. @@ -45,11 +68,12 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai xblockView.render({ success: function() { xblockView.xblock.runtime.notify("page-shown", self); - xblockView.$el.removeClass('is-hidden'); + xblockView.$el.removeClass(hiddenCss); self.renderAddXBlockComponents(); self.onXBlockRefresh(xblockView); self.refreshDisplayName(); - loadingElement.addClass('is-hidden'); + loadingElement.addClass(hiddenCss); + unitLocationTree.removeClass(hiddenCss); self.delegateEvents(); } }); @@ -71,6 +95,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai onXBlockRefresh: function(xblockView) { this.addButtonActions(xblockView.$el); this.xblockView.refresh(); + // Update publish and last modified information from the server. + this.model.fetch(); }, renderAddXBlockComponents: function() { @@ -181,6 +207,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai xblockElement.remove(); xblockView.updateChildren(parent); xblock.runtime.notify('deleted-child', parent.data('locator')); + // Update publish and last modified information from the server. + this.model.fetch(); }, onNewXBlock: function(xblockElement, scrollOffset, data) { diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js new file mode 100644 index 0000000000..df347ae667 --- /dev/null +++ b/cms/static/js/views/pages/container_subviews.js @@ -0,0 +1,167 @@ +/** + * Subviews (usually small side panels) for XBlockContainerPage. + */ +define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/feedback_prompt"], + function ($, _, gettext, BaseView, PromptView) { + + var disabledCss = "is-disabled"; + + /** + * A view that calls render when "has_changes" or "published" values in XBlockInfo have changed + * after a server sync operation. + */ + var UnitStateListenerView = BaseView.extend({ + + // takes XBlockInfo as a model + initialize: function() { + this.model.on('sync', this.onSync, this); + }, + + onSync: function(e) { + if (e.changedAttributes() && + (('has_changes' in e.changedAttributes()) || ('published' in e.changedAttributes()))) { + this.render(); + } + }, + + render: function() {} + }); + + /** + * A controller for updating the visibility status of the unit on the RHS navigation tree. + */ + var VisibilityStateController = UnitStateListenerView.extend({ + + render: function() { + var computeState = function(published, has_changes) { + if (!published) { + return "private"; + } + else if (has_changes) { + return "draft"; + } + else { + return "public"; + } + }; + var state = computeState(this.model.get('published'), this.model.get('has_changes')); + this.$el.removeClass("private-item public-item draft-item"); + this.$el.addClass(state + "-item"); + } + }); + + /** + * A controller for updating the "View Live" and "Preview" buttons. + */ + var PreviewActionController = UnitStateListenerView.extend({ + + render: function() { + var previewAction = this.$el.find('.preview-button'), + viewLiveAction = this.$el.find('.view-button'); + if (this.model.get('published')) { + viewLiveAction.removeClass(disabledCss); + } + else { + viewLiveAction.addClass(disabledCss); + } + if (this.model.get('has_changes') || !this.model.get('published')) { + previewAction.removeClass(disabledCss); + } + else { + previewAction.addClass(disabledCss); + } + } + }); + + /** + * Publisher is a view that supports the following: + * 1) Publishing of a draft version of an xblock. + * 2) Discarding of edits in a draft version. + * 3) Display of who last edited the xblock, and when. + * 4) Display of publish status (published, published with changes, changes with no published version). + */ + var Publisher = BaseView.extend({ + events: { + 'click .action-publish': 'publish', + 'click .action-discard': 'discardChanges' + }, + + // takes XBlockInfo as a model + + initialize: function () { + BaseView.prototype.initialize.call(this); + this.template = this.loadTemplate('publish-xblock'); + this.model.on('sync', this.onSync, this); + }, + + onSync: function(e) { + if (e.changedAttributes() && + (('has_changes' in e.changedAttributes()) || ('published' in e.changedAttributes()) || + ('edited_on' in e.changedAttributes()) || ('edited_by' in e.changedAttributes()))) { + this.render(); + } + }, + + render: function () { + this.$el.html(this.template({ + has_changes: this.model.get('has_changes'), + published: this.model.get('published'), + edited_on: this.model.get('edited_on'), + edited_by: this.model.get('edited_by') + })); + + return this; + }, + + publish: function (e) { + var xblockInfo = this.model; + if (e && e.preventDefault) { + e.preventDefault(); + } + this.runOperationShowingMessage(gettext('Publishing…'), + function () { + return xblockInfo.save({publish: 'make_public'}); + }).done(function () { + xblockInfo.fetch(); + }); + }, + + discardChanges: function (e) { + if (e && e.preventDefault) { + e.preventDefault(); + } + var xblockInfo = this.model, view; + + view = new PromptView.Warning({ + title: gettext("Discard Changes"), + message: gettext("Are you sure you want to discard changes and revert to the last published version?"), + actions: { + primary: { + text: gettext("Discard Changes"), + click: function (view) { + view.hide(); + $.ajax({ + type: 'DELETE', + url: xblockInfo.url() + }).success(function () { + return window.location.reload(); + }); + } + }, + secondary: { + text: gettext("Cancel"), + click: function (view) { + view.hide(); + } + } + } + }).show(); + } + }); + + return { + 'VisibilityStateController': VisibilityStateController, + 'PreviewActionController': PreviewActionController, + 'Publisher': Publisher + }; + }); // end define(); diff --git a/cms/static/js/views/xblock_string_field_editor.js b/cms/static/js/views/xblock_string_field_editor.js index 4c1fa40360..a4b44e0664 100644 --- a/cms/static/js/views/xblock_string_field_editor.js +++ b/cms/static/js/views/xblock_string_field_editor.js @@ -77,7 +77,8 @@ define(["jquery", "gettext", "js/views/baseview"], function() { return xblockInfo.save(requestData); }).done(function() { - xblockInfo.set(fieldName, newValue); + // Update publish and last modified information from the server. + xblockInfo.fetch(); }); }, diff --git a/cms/templates/container.html b/cms/templates/container.html index 9f2d5b7b0e..00e620abfc 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -26,30 +26,30 @@ from django.utils.translation import ugettext as _ + + % endfor + <%block name="jsextra"> - -<% -main_xblock_info = { - 'id': str(xblock_locator), - 'display_name': xblock.display_name_with_default, - 'category': xblock.category, -}; -%>