diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 09e78efc40..79f778ae9b 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1197,7 +1197,7 @@ class ContentStoreTest(ContentStoreTestCase): resp = self._show_course_overview(course.id) self.assertContains( resp, - '
'.format( + '
'.format( locator='i4x://MITx/999/course/Robot_Super_Course', course_key='MITx/999/Robot_Super_Course', ), diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index a246ec28a9..948a5a636b 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -181,6 +181,7 @@ def xblock_handler(request, usage_key_string): content_type="text/plain" ) + # pylint: disable=unused-argument @require_http_methods(("GET")) @login_required @@ -449,7 +450,7 @@ def _create_item(request): # if we add one then we need to also add it to the policy information (i.e. metadata) # we should remove this once we can break this reference from the course to static tabs if category == 'static_tab': - display_name = display_name or _("Empty") # Prevent name being None + display_name = display_name or _("Empty") # Prevent name being None course = store.get_course(dest_usage_key.course_key) course.tabs.append( StaticTab( @@ -635,7 +636,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F return None is_xblock_unit = is_unit(xblock, parent_xblock) - is_unit_with_changes = is_xblock_unit and modulestore().has_changes(xblock) + has_changes = modulestore().has_changes(xblock) if graders is None: graders = CourseGradingModel.fetch(xblock.location.course_key).graders @@ -654,7 +655,10 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F # Treat DEFAULT_START_DATE as a magic number that means the release date has not been set release_date = get_default_time_display(xblock.start) if xblock.start != DEFAULT_START_DATE else None - visibility_state = _compute_visibility_state(xblock, child_info, is_unit_with_changes) if not xblock.category == 'course' else None + if xblock.category != 'course': + visibility_state = _compute_visibility_state(xblock, child_info, is_xblock_unit and has_changes) + else: + visibility_state = None published = modulestore().compute_publish_state(xblock) != PublishState.private xblock_info = { @@ -664,7 +668,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F "edited_on": get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None, "published": published, "published_on": get_default_time_display(xblock.published_date) if xblock.published_date else None, - 'studio_url': xblock_studio_url(xblock, parent_xblock), + "studio_url": xblock_studio_url(xblock, parent_xblock), "released_to_students": datetime.now(UTC) > xblock.start, "release_date": release_date, "visibility_state": visibility_state, @@ -675,6 +679,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F "due": xblock.fields['due'].to_json(xblock.due), "format": xblock.format, "course_graders": json.dumps([grader.get('type') for grader in graders]), + "has_changes": has_changes, } if data is not None: xblock_info["data"] = data @@ -689,14 +694,13 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F else: xblock_info["ancestor_has_staff_lock"] = False - # Currently, 'edited_by', 'published_by', and 'release_date_from', and 'has_changes' are only used by the + # Currently, 'edited_by', 'published_by', and 'release_date_from' are only used by the # container page when rendering a unit. Since they are expensive to compute, only include them for units # that are not being rendered on the course outline. if is_xblock_unit and not course_outline: xblock_info["edited_by"] = safe_get_username(xblock.subtree_edited_by) xblock_info["published_by"] = safe_get_username(xblock.published_by) xblock_info["currently_visible_to_students"] = is_currently_visible_to_students(xblock) - xblock_info['has_changes'] = is_unit_with_changes if release_date: xblock_info["release_date_from"] = _get_release_date_from(xblock) if visibility_state == VisibilityState.staff_only: diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js index 3004fd1b4a..d2324313b5 100644 --- a/cms/static/js/models/xblock_info.js +++ b/cms/static/js/models/xblock_info.js @@ -59,7 +59,7 @@ function(Backbone, _, str, ModuleUtils) { */ "visibility_state": null, /** - * True iff the release date of the xblock is in the past. + * True if the release date of the xblock is in the past. */ 'released_to_students': null, /** @@ -153,6 +153,10 @@ function(Backbone, _, str, ModuleUtils) { return childInfo && childInfo.children.length > 0; }, + isPublishable: function(){ + return !this.get('published') || this.get('has_changes'); + }, + /** * Return a list of convenience methods to check affiliation to the category. * @return {Array} diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js index 41f57cec85..352c201d9a 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -5,14 +5,14 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" describe("CourseOutlinePage", function() { var createCourseOutlinePage, displayNameInput, model, outlinePage, requests, getItemsOfType, getItemHeaders, verifyItemsExpanded, expandItemsAndVerifyState, collapseItemsAndVerifyState, - createMockCourseJSON, createMockSectionJSON, createMockSubsectionJSON, - mockCourseJSON, mockEmptyCourseJSON, mockSingleSectionCourseJSON, + createMockCourseJSON, createMockSectionJSON, createMockSubsectionJSON, verifyTypePublishable, + mockCourseJSON, mockEmptyCourseJSON, mockSingleSectionCourseJSON, createMockVerticalJSON, mockOutlinePage = readFixtures('mock/mock-course-outline-page.underscore'); - createMockCourseJSON = function(id, displayName, children) { - return { - id: id, - display_name: displayName, + createMockCourseJSON = function(options, children) { + return $.extend(true, {}, { + id: 'mock-course', + display_name: 'Mock Course', category: 'course', studio_url: '/course/slashes:MockCourse', is_container: true, @@ -22,17 +22,18 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" edited_by: 'MockUser', has_explicit_staff_lock: false, child_info: { - display_name: 'Section', category: 'chapter', - children: children + display_name: 'Section', + children: [] } - }; + }, options, {child_info: {children: children}}); }; - createMockSectionJSON = function(id, displayName, children) { - return { - id: id, + + createMockSectionJSON = function(options, children) { + return $.extend(true, {}, { + id: 'mock-section', + display_name: 'Mock Section', category: 'chapter', - display_name: displayName, studio_url: '/course/slashes:MockCourse', is_container: true, has_changes: false, @@ -43,14 +44,15 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" child_info: { category: 'sequential', display_name: 'Subsection', - children: children + children: [] } - }; + }, options, {child_info: {children: children}}); }; - createMockSubsectionJSON = function(id, displayName, children) { - return { - id: id, - display_name: displayName, + + createMockSubsectionJSON = function(options, children) { + return $.extend(true, {}, { + id: 'mock-subsection', + display_name: 'Mock Subsection', category: 'sequential', studio_url: '/course/slashes:MockCourse', is_container: true, @@ -63,9 +65,24 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" child_info: { category: 'vertical', display_name: 'Unit', - children: children + children: [] } - }; + }, options, {child_info: {children: children}}); + }; + + createMockVerticalJSON = function(options) { + return $.extend(true, {}, { + id: 'mock-unit', + display_name: 'Mock Unit', + category: 'vertical', + studio_url: '/container/mock-unit', + is_container: true, + has_changes: false, + published: true, + visibility_state: 'unscheduled', + edited_on: 'Jul 02, 2014 at 20:56 UTC', + edited_by: 'MockUser' + }, options); }; getItemsOfType = function(type) { @@ -108,40 +125,100 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" return outlinePage; }; + verifyTypePublishable = function (type, getMockCourseJSON) { + var createCourseOutlinePageAndShowUnit, verifyPublishButton; + + createCourseOutlinePageAndShowUnit = function (test, courseJSON, createOnly) { + outlinePage = createCourseOutlinePage.apply(this, arguments); + if (type === 'unit') { + expandItemsAndVerifyState('subsection'); + } + }; + + verifyPublishButton = function (test, courseJSON, createOnly) { + createCourseOutlinePageAndShowUnit.apply(this, arguments); + expect(getItemHeaders(type).find('.publish-button')).toExist(); + }; + + it('can be published', function() { + var mockCourseJSON = getMockCourseJSON({ + has_changes: true + }); + createCourseOutlinePageAndShowUnit(this, mockCourseJSON); + getItemHeaders(type).find('.publish-button').click(); + $(".wrapper-modal-window .action-publish").click(); + create_sinon.expectJsonRequest(requests, 'POST', '/xblock/mock-' + type, { + publish : 'make_public' + }); + expect(requests[0].requestHeaders['X-HTTP-Method-Override']).toBe('PATCH'); + create_sinon.respondWithJson(requests, {}); + create_sinon.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-section'); + }); + + it('should show publish button if it is not published and not changed', function() { + var mockCourseJSON = getMockCourseJSON({ + has_changes: false, + published: false + }); + verifyPublishButton(this, mockCourseJSON); + }); + + it('should show publish button if it is published and changed', function() { + var mockCourseJSON = getMockCourseJSON({ + has_changes: true, + published: true + }); + verifyPublishButton(this, mockCourseJSON); + }); + + it('should show publish button if it is not published, but changed', function() { + var mockCourseJSON = getMockCourseJSON({ + has_changes: true, + published: false + }); + verifyPublishButton(this, mockCourseJSON); + }); + + it('should hide publish button if it is not changed, but published', function() { + var mockCourseJSON = getMockCourseJSON({ + has_changes: false, + published: true + }); + createCourseOutlinePageAndShowUnit(this, mockCourseJSON); + expect(getItemHeaders(type).find('.publish-button')).not.toExist(); + }); + }; + beforeEach(function () { view_helpers.installMockAnalytics(); view_helpers.installViewTemplates(); - view_helpers.installTemplate('course-outline'); - view_helpers.installTemplate('xblock-string-field-editor'); - view_helpers.installTemplate('modal-button'); - view_helpers.installTemplate('basic-modal'); - view_helpers.installTemplate('edit-outline-item-modal'); + view_helpers.installTemplates([ + 'course-outline', 'xblock-string-field-editor', 'modal-button', + 'basic-modal', 'course-outline-modal', 'release-date-editor', + 'due-date-editor', 'grading-editor', 'publish-editor', + 'staff-lock-editor' + ]); appendSetFixtures(mockOutlinePage); - mockCourseJSON = createMockCourseJSON('mock-course', 'Mock Course', [ - createMockSectionJSON('mock-section', 'Mock Section', [ - createMockSubsectionJSON('mock-subsection', 'Mock Subsection', [{ - id: 'mock-unit', - display_name: 'Mock Unit', - category: 'vertical', - studio_url: '/container/mock-unit', - is_container: true, - has_changes: false, - published: true, - visibility_state: 'unscheduled', - edited_on: 'Jul 02, 2014 at 20:56 UTC', - edited_by: 'MockUser' - }]) + mockCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({}, [ + createMockVerticalJSON() + ]) ]) ]); - mockEmptyCourseJSON = createMockCourseJSON('mock-course', 'Mock Course', []); - mockSingleSectionCourseJSON = createMockCourseJSON('mock-course', 'Mock Course', [ - createMockSectionJSON('mock-section', 'Mock Section', []) + mockEmptyCourseJSON = createMockCourseJSON(); + mockSingleSectionCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON() ]); }); afterEach(function () { view_helpers.removeMockAnalytics(); edit_helpers.cancelModalIfShowing(); + // Clean up after the $.datepicker + $("#start_date").datepicker( "destroy" ); + $("#due_date").datepicker( "destroy" ); + $('.ui-datepicker').remove(); }); describe('Initial display', function() { @@ -201,7 +278,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" // Expect the UI to just fetch the new section and repaint it create_sinon.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-section-2'); create_sinon.respondWithJson(requests, - createMockSectionJSON('mock-section-2', 'Mock Section 2', [])); + createMockSectionJSON({id: 'mock-section-2', display_name: 'Mock Section 2'})); sectionElements = getItemsOfType('section'); expect(sectionElements.length).toBe(2); expect($(sectionElements[0]).data('locator')).toEqual('mock-section'); @@ -269,9 +346,9 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" it('can be deleted', function() { var promptSpy = view_helpers.createPromptSpy(), requestCount; - createCourseOutlinePage(this, createMockCourseJSON('mock-course', 'Mock Course', [ - createMockSectionJSON('mock-section', 'Mock Section', []), - createMockSectionJSON('mock-section-2', 'Mock Section 2', []) + createCourseOutlinePage(this, createMockCourseJSON({}, [ + createMockSectionJSON(), + createMockSectionJSON({id: 'mock-section-2', display_name: 'Mock Section 2'}) ])); getItemHeaders('section').find('.delete-button').first().click(); view_helpers.confirmPrompt(promptSpy); @@ -356,7 +433,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" outlinePage.$('.section-header-actions .configure-button').click(); $("#start_date").val("1/2/2015"); // Section release date can't be cleared. - expect($(".edit-outline-item-modal .action-clear")).not.toExist(); + expect($(".wrapper-modal-window .action-clear")).not.toExist(); // Section does not contain due_date or grading type selector expect($("due_date")).not.toExist(); @@ -364,9 +441,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" // Staff lock controls are always visible expect($("#staff_lock")).toExist(); - - $(".edit-outline-item-modal .action-save").click(); - + $(".wrapper-modal-window .action-save").click(); create_sinon.expectJsonRequest(requests, 'POST', '/xblock/mock-section', { "metadata":{ "start":"2015-01-02T00:00:00.000Z" @@ -376,25 +451,16 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" // This is the response for the change operation. create_sinon.respondWithJson(requests, {}); - var mockResponseSectionJSON = $.extend(true, {}, - createMockSectionJSON('mock-section', 'Mock Section', [ - createMockSubsectionJSON('mock-subsection', 'Mock Subsection', [{ - id: 'mock-unit', - display_name: 'Mock Unit', - category: 'vertical', - studio_url: '/container/mock-unit', - is_container: true, - has_changes: true, - published: false, - edited_on: 'Jul 02, 2014 at 20:56 UTC', - edited_by: 'MockUser' - } + var mockResponseSectionJSON = createMockSectionJSON({ + release_date: 'Jan 02, 2015 at 00:00 UTC' + }, [ + createMockSubsectionJSON({}, [ + createMockVerticalJSON({ + has_changes: true, + published: false + }) ]) - ]), - { - release_date: 'Jan 02, 2015 at 00:00 UTC', - } - ); + ]); create_sinon.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-section') expect(requests.length).toBe(2); // This is the response for the subsequent fetch operation for the section. @@ -402,6 +468,46 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" expect($(".outline-section .status-release-value")).toContainText("Jan 02, 2015 at 00:00 UTC"); }); + + verifyTypePublishable('section', function (options) { + return createMockCourseJSON({}, [ + createMockSectionJSON(options, [ + createMockSubsectionJSON({}, [ + createMockVerticalJSON() + ]) + ]) + ]); + }); + + it('can display a publish modal with a list of unpublished subsections and units', function () { + var mockCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON({has_changes: true}, [ + createMockSubsectionJSON({has_changes: true}, [ + createMockVerticalJSON(), + createMockVerticalJSON({has_changes: true, display_name: 'Unit 100'}), + createMockVerticalJSON({published: false, display_name: 'Unit 50'}) + ]), + createMockSubsectionJSON({has_changes: true}, [ + createMockVerticalJSON({has_changes: true, display_name: 'Unit 1'}) + ]), + createMockSubsectionJSON({}, [createMockVerticalJSON]) + ]), + createMockSectionJSON({has_changes: true}, [ + createMockSubsectionJSON({has_changes: true}, [ + createMockVerticalJSON({has_changes: true}), + ]) + ]) + ]), modalWindow; + + createCourseOutlinePage(this, mockCourseJSON, false); + getItemHeaders('section').first().find('.publish-button').click(); + modalWindow = $('.wrapper-modal-window'); + expect(modalWindow.find('.outline-unit').length).toBe(3); + expect(_.compact(_.map(modalWindow.find('.outline-unit').text().split("\n"), $.trim))).toEqual( + ['Unit 100', 'Unit 50', 'Unit 1'] + ) + expect(modalWindow.find('.outline-subsection').length).toBe(2); + }); }); describe("Subsection", function() { @@ -419,37 +525,25 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" }; // Contains hard-coded dates because dates are presented in different formats. - mockServerValuesJson = $.extend(true, {}, - createMockSectionJSON('mock-section', 'Mock Section', [ - createMockSubsectionJSON('mock-subsection', 'Mock Subsection', [{ - id: 'mock-unit', - display_name: 'Mock Unit', - category: 'vertical', - studio_url: '/container/mock-unit', - is_container: true, - has_changes: true, - published: false, - edited_on: 'Jul 02, 2014 at 20:56 UTC', - edited_by: 'MockUser' - } + var mockServerValuesJson = createMockSectionJSON({ + release_date: 'Jan 01, 2970 at 05:00 UTC' + }, [ + createMockSubsectionJSON({ + graded: true, + due_date: 'Jul 10, 2014 at 00:00 UTC', + release_date: 'Jul 09, 2014 at 00:00 UTC', + start: "2014-07-09T00:00:00Z", + format: "Lab", + due: "2014-07-10T00:00:00Z", + has_explicit_staff_lock: true, + staff_only_message: true + }, [ + createMockVerticalJSON({ + has_changes: true, + published: false + }) ]) - ]), - { - release_date: 'Jan 01, 2970 at 05:00 UTC', - child_info: { //Section child_info - children: [{ // Section children - graded: true, - due_date: 'Jul 10, 2014 at 00:00 UTC', - release_date: 'Jul 09, 2014 at 00:00 UTC', - start: "2014-07-09T00:00:00Z", - format: "Lab", - due: "2014-07-10T00:00:00Z", - has_explicit_staff_lock: true, - staff_only_message: true - }] - } - } - ); + ]); it('can be deleted', function() { var promptSpy = view_helpers.createPromptSpy(); @@ -492,9 +586,12 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" create_sinon.respondWithJson(requests, { }); // This is the response for the subsequent fetch operation for the section. create_sinon.respondWithJson(requests, - createMockSectionJSON('mock-section', 'Mock Section', [ - createMockSubsectionJSON('mock-subsection', updatedDisplayName, []) - ])); + createMockSectionJSON({}, [ + createMockSubsectionJSON({ + display_name: updatedDisplayName + }) + ]) + ); // Find the display name again in the refreshed DOM and verify it displayNameWrapper = getItemHeaders('subsection').find('.wrapper-xblock-field'); view_helpers.verifyInlineEditChange(displayNameWrapper, updatedDisplayName); @@ -514,7 +611,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" createCourseOutlinePage(this, mockCourseJSON, false); outlinePage.$('.outline-subsection .configure-button').click(); setEditModalValues("7/9/2014", "7/10/2014", "Lab", true); - $(".edit-outline-item-modal .action-save").click(); + $(".wrapper-modal-window .action-save").click(); create_sinon.expectJsonRequest(requests, 'POST', '/xblock/mock-subsection', { "graderType":"Lab", "publish": "republish", @@ -550,7 +647,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" createCourseOutlinePage(this, mockCourseJSON, false); outlinePage.$('.outline-item .outline-subsection .configure-button').click(); setEditModalValues("7/9/2014", "7/10/2014", "Lab", true); - $(".edit-outline-item-modal .action-save").click(); + $(".wrapper-modal-window .action-save").click(); // This is the response for the change operation. create_sinon.respondWithJson(requests, {}); @@ -568,29 +665,62 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" expect($("#grading_type").val()).toBe('Lab'); expect($("#staff_lock").is(":checked")).toBe(true); - $(".edit-outline-item-modal .scheduled-date-input .action-clear").click(); - $(".edit-outline-item-modal .due-date-input .action-clear").click(); + $(".wrapper-modal-window .scheduled-date-input .action-clear").click(); + $(".wrapper-modal-window .due-date-input .action-clear").click(); expect($("#start_date").val()).toBe(''); expect($("#due_date").val()).toBe(''); $("#grading_type").val('notgraded'); $("#staff_lock").prop('checked', false); - $(".edit-outline-item-modal .action-save").click(); + $(".wrapper-modal-window .action-save").click(); // 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, - createMockSectionJSON('mock-section', 'Mock Section', [ - createMockSubsectionJSON('mock-subsection', 'Mock Subsection', []) - ]) + createMockSectionJSON({}, [createMockSubsectionJSON()]) ); expect($(".outline-subsection .status-release-value")).not.toContainText("Jul 09, 2014 at 00:00 UTC"); expect($(".outline-subsection .status-grading-date")).not.toExist(); expect($(".outline-subsection .status-grading-value")).not.toExist(); expect($(".outline-subsection .status-message-copy")).not.toContainText("Contains staff only content"); }); + + verifyTypePublishable('subsection', function (options) { + return createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON(options, [ + createMockVerticalJSON() + ]) + ]) + ]); + }); + + it('can display a publish modal with a list of unpublished units', function () { + var mockCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON({has_changes: true}, [ + createMockSubsectionJSON({has_changes: true}, [ + createMockVerticalJSON(), + createMockVerticalJSON({has_changes: true, display_name: "Unit 100"}), + createMockVerticalJSON({published: false, display_name: "Unit 50"}) + ]), + createMockSubsectionJSON({has_changes: true}, [ + createMockVerticalJSON({has_changes: true}) + ]), + createMockSubsectionJSON({}, [createMockVerticalJSON]) + ]) + ]), modalWindow; + + createCourseOutlinePage(this, mockCourseJSON, false); + getItemHeaders('subsection').first().find('.publish-button').click(); + modalWindow = $('.wrapper-modal-window'); + expect(modalWindow.find('.outline-unit').length).toBe(2); + expect(_.compact(_.map(modalWindow.find('.outline-unit').text().split("\n"), $.trim))).toEqual( + ['Unit 100', 'Unit 50'] + ) + expect(modalWindow.find('.outline-subsection')).not.toExist(); + }); }); // Note: most tests for units can be found in Bok Choy @@ -615,6 +745,16 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" unitAnchor = getItemsOfType('unit').find('.unit-title a'); expect(unitAnchor.attr('href')).toBe('/container/mock-unit'); }); + + verifyTypePublishable('unit', function (options) { + return createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({}, [ + createMockVerticalJSON(options) + ]) + ]) + ]); + }); }); describe("Date and Time picker", function() { diff --git a/cms/static/js/views/course_outline.js b/cms/static/js/views/course_outline.js index a2e5306eac..d7a9aa9d67 100644 --- a/cms/static/js/views/course_outline.js +++ b/cms/static/js/views/course_outline.js @@ -8,9 +8,12 @@ * - changes cause a refresh of the entire section rather than just the view for the changed xblock * - adding units will automatically redirect to the unit page rather than showing them inline */ -define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_utils", - "js/models/xblock_outline_info", "js/views/modals/edit_outline_item", "js/utils/drag_and_drop"], - function($, _, XBlockOutlineView, ViewUtils, XBlockOutlineInfo, EditSectionXBlockModal, ContentDragger) { +define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_utils", "js/views/utils/xblock_utils", + "js/models/xblock_outline_info", "js/views/modals/course_outline_modals", "js/utils/drag_and_drop"], + function( + $, _, XBlockOutlineView, ViewUtils, XBlockViewUtils, + XBlockOutlineInfo, CourseOutlineModalsFactory, ContentDragger + ) { var CourseOutlineView = XBlockOutlineView.extend({ // takes XBlockOutlineInfo as a model @@ -144,13 +147,30 @@ define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_ }, editXBlock: function() { - var modal = new EditSectionXBlockModal({ - model: this.model, + var modal = CourseOutlineModalsFactory.getModal('edit', this.model, { onSave: this.refresh.bind(this), - parentInfo: this.parentInfo + parentInfo: this.parentInfo, + xblockType: XBlockViewUtils.getXBlockType( + this.model.get('category'), this.parentView.model, true + ) }); - modal.show(); + if (modal) { + modal.show(); + } + }, + + publishXBlock: function() { + var modal = CourseOutlineModalsFactory.getModal('publish', this.model, { + onSave: this.refresh.bind(this), + xblockType: XBlockViewUtils.getXBlockType( + this.model.get('category'), this.parentView.model, true + ) + }); + + if (modal) { + modal.show(); + } }, addButtonActions: function(element) { @@ -159,6 +179,10 @@ define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_ event.preventDefault(); this.editXBlock(); }.bind(this)); + element.find('.publish-button').click(function(event) { + event.preventDefault(); + this.publishXBlock(); + }.bind(this)); }, makeContentDraggable: function(element) { diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js new file mode 100644 index 0000000000..e46cd710de --- /dev/null +++ b/cms/static/js/views/modals/course_outline_modals.js @@ -0,0 +1,379 @@ +/** + * The CourseOutlineXBlockModal is a Backbone view that shows an editor in a modal window. + * It has nested views: for release date, due date and grading format. + * It is invoked using the editXBlock method and uses xblock_info as a model, + * and upon save parent invokes refresh function that fetches updated model and + * re-renders edited course outline. + */ +define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', + 'js/views/modals/base_modal', 'date', 'js/views/utils/xblock_utils', + 'js/utils/date_utils' +], function( + $, Backbone, _, gettext, BaseView, BaseModal, date, XBlockViewUtils, DateUtils +) { + 'use strict'; + var CourseOutlineXBlockModal, SettingsXBlockModal, PublishXBlockModal, AbstractEditor, BaseDateEditor, + ReleaseDateEditor, DueDateEditor, GradingEditor, PublishEditor, StaffLockEditor; + + CourseOutlineXBlockModal = BaseModal.extend({ + events : { + 'click .action-save': 'save' + }, + + options: $.extend({}, BaseModal.prototype.options, { + modalName: 'course-outline', + modalType: 'edit-settings', + addSaveButton: true, + modalSize: 'med', + viewSpecificClasses: 'confirm', + editors: [] + }), + + initialize: function() { + BaseModal.prototype.initialize.call(this); + this.events = $.extend({}, BaseModal.prototype.events, this.events); + this.template = this.loadTemplate('course-outline-modal'); + this.options.title = this.getTitle(); + }, + + afterRender: function () { + BaseModal.prototype.afterRender.call(this); + this.initializeEditors(); + }, + + initializeEditors: function () { + this.options.editors = _.map(this.options.editors, function (Editor) { + return new Editor({ + parentElement: this.$('.modal-section'), + model: this.model, + xblockType: this.options.xblockType + }); + }, this); + }, + + getTitle: function () { + return ''; + }, + + getIntroductionMessage: function () { + return ''; + }, + + getContentHtml: function() { + return this.template(this.getContext()); + }, + + save: function(event) { + event.preventDefault(); + var requestData = this.getRequestData(); + if (!_.isEqual(requestData, { metadata: {} })) { + XBlockViewUtils.updateXBlockFields(this.model, requestData, { + success: this.options.onSave + }); + } + this.hide(); + }, + + /** + * Return context for the modal. + * @return {Object} + */ + getContext: function () { + return $.extend({ + xblockInfo: this.model, + introductionMessage: this.getIntroductionMessage() + }); + }, + + /** + * Return request data. + * @return {Object} + */ + getRequestData: function () { + var requestData = _.map(this.options.editors, function (editor) { + return editor.getRequestData(); + }); + + return $.extend.apply(this, [true, {}].concat(requestData)); + } + }); + + SettingsXBlockModal = CourseOutlineXBlockModal.extend({ + getTitle: function () { + return interpolate( + gettext('%(display_name)s Settings'), + { display_name: this.model.get('display_name') }, true + ); + }, + + getIntroductionMessage: function () { + return interpolate( + gettext('Change the settings for %(display_name)s'), + { display_name: this.model.get('display_name') }, true + ); + } + }); + + + PublishXBlockModal = CourseOutlineXBlockModal.extend({ + events : { + 'click .action-publish': 'save' + }, + + initialize: function() { + CourseOutlineXBlockModal.prototype.initialize.call(this); + if (this.options.xblockType) { + this.options.modalName = 'bulkpublish-' + this.options.xblockType; + } + }, + + getTitle: function () { + return interpolate( + gettext('Publish %(display_name)s'), + { display_name: this.model.get('display_name') }, true + ); + }, + + getIntroductionMessage: function () { + return interpolate( + gettext('Publish all unpublished changes for this %(item)s?'), + { item: this.options.xblockType }, true + ); + }, + + addActionButtons: function() { + this.addActionButton('publish', gettext('Publish'), true); + this.addActionButton('cancel', gettext('Cancel')); + } + }); + + + AbstractEditor = BaseView.extend({ + tagName: 'section', + templateName: null, + initialize: function() { + this.template = this.loadTemplate(this.templateName); + this.parentElement = this.options.parentElement; + this.render(); + }, + + render: function () { + var html = this.template($.extend({}, { + xblockInfo: this.model, + xblockType: this.options.xblockType + }, this.getContext())); + + this.$el.html(html); + this.parentElement.append(this.$el); + }, + + getContext: function () { + return {}; + }, + + getRequestData: function () { + return {}; + } + }); + + BaseDateEditor = AbstractEditor.extend({ + // Attribute name in the model, should be defined in children classes. + fieldName: null, + + events : { + 'click .clear-date': 'clearValue' + }, + + afterRender: function () { + AbstractEditor.prototype.afterRender.call(this); + this.$('input.date').datepicker({'dateFormat': 'm/d/yy'}); + this.$('input.time').timepicker({ + 'timeFormat' : 'H:i', + 'forceRoundTime': true + }); + if (this.model.get(this.fieldName)) { + DateUtils.setDate( + this.$('input.date'), this.$('input.time'), + this.model.get(this.fieldName) + ); + } + } + }); + + DueDateEditor = BaseDateEditor.extend({ + fieldName: 'due', + templateName: 'due-date-editor', + className: 'modal-section-content has-actions due-date-input grading-due-date', + + getValue: function () { + return DateUtils.getDate(this.$('#due_date'), this.$('#due_time')); + }, + + clearValue: function (event) { + event.preventDefault(); + this.$('#due_time, #due_date').val(''); + }, + + getRequestData: function () { + return { + metadata: { + 'due': this.getValue() + } + }; + } + }); + + ReleaseDateEditor = BaseDateEditor.extend({ + fieldName: 'start', + templateName: 'release-date-editor', + className: 'edit-settings-release scheduled-date-input', + startingReleaseDate: null, + + afterRender: function () { + BaseDateEditor.prototype.afterRender.call(this); + // Store the starting date and time so that we can determine if the user + // actually changed it when "Save" is pressed. + this.startingReleaseDate = this.getValue(); + }, + + getValue: function () { + return DateUtils.getDate(this.$('#start_date'), this.$('#start_time')); + }, + + clearValue: function (event) { + event.preventDefault(); + this.$('#start_time, #start_date').val(''); + }, + + getRequestData: function () { + var newReleaseDate = this.getValue(); + if (JSON.stringify(newReleaseDate) === JSON.stringify(this.startingReleaseDate)) { + return {}; + } + return { + metadata: { + 'start': newReleaseDate + } + }; + } + }); + + GradingEditor = AbstractEditor.extend({ + templateName: 'grading-editor', + className: 'edit-settings-grading', + + afterRender: function () { + AbstractEditor.prototype.afterRender.call(this); + this.setValue(this.model.get('format')); + }, + + setValue: function (value) { + this.$('#grading_type').val(value); + }, + + getValue: function () { + return this.$('#grading_type').val(); + }, + + getRequestData: function () { + return { + 'graderType': this.getValue() + }; + }, + + getContext: function () { + return { + graderTypes: JSON.parse(this.model.get('course_graders')) + }; + } + }); + + PublishEditor = AbstractEditor.extend({ + templateName: 'publish-editor', + className: 'edit-settings-publish', + getRequestData: function () { + return { + publish: 'make_public' + }; + } + }); + + StaffLockEditor = AbstractEditor.extend({ + templateName: 'staff-lock-editor', + className: 'edit-staff-lock', + isModelLocked: function() { + return this.model.get('has_explicit_staff_lock'); + }, + + isAncestorLocked: function() { + return this.model.get('ancestor_has_staff_lock'); + }, + + afterRender: function () { + AbstractEditor.prototype.afterRender.call(this); + this.setLock(this.isModelLocked()); + }, + + setLock: function(value) { + this.$('#staff_lock').prop('checked', value); + }, + + isLocked: function() { + return this.$('#staff_lock').is(':checked'); + }, + + hasChanges: function() { + return this.isModelLocked() != this.isLocked(); + }, + + getRequestData: function() { + return this.hasChanges() ? { + publish: 'republish', + metadata: { + visible_to_staff_only: this.isLocked() ? true : null + } + } : {}; + }, + + getContext: function () { + return { + hasExplicitStaffLock: this.isModelLocked(), + ancestorLocked: this.isAncestorLocked() + } + } + }); + + return { + getModal: function (type, xblockInfo, options) { + if (type === 'edit') { + return this.getEditModal(xblockInfo, options); + } else if (type === 'publish') { + return this.getPublishModal(xblockInfo, options); + } + }, + + getEditModal: function (xblockInfo, options) { + var editors = []; + + if (xblockInfo.isChapter()) { + editors = [ReleaseDateEditor, StaffLockEditor]; + } else if (xblockInfo.isSequential()) { + editors = [ReleaseDateEditor, GradingEditor, DueDateEditor, StaffLockEditor]; + } else if (xblockInfo.isVertical()) { + editors = [StaffLockEditor]; + } + + return new SettingsXBlockModal($.extend({ + editors: editors, + model: xblockInfo + }, options)); + }, + + getPublishModal: function (xblockInfo, options) { + return new PublishXBlockModal($.extend({ + editors: [PublishEditor], + model: xblockInfo + }, options)); + } + }; +}); diff --git a/cms/static/js/views/modals/edit_outline_item.js b/cms/static/js/views/modals/edit_outline_item.js deleted file mode 100644 index 2be69b3831..0000000000 --- a/cms/static/js/views/modals/edit_outline_item.js +++ /dev/null @@ -1,299 +0,0 @@ -/** - * The EditSectionXBlockModal is a Backbone view that shows an editor in a modal window. - * It has nested views: for release date, due date, grading format, and staff lock. - * It is invoked using the editXBlock method and uses xblock_info as a model, - * and upon save parent invokes refresh function that fetches updated model and - * re-renders edited course outline. - */ -define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/modals/base_modal', - 'date', 'js/views/utils/xblock_utils', 'js/utils/date_utils', 'js/views/utils/view_utils' -], - function( - $, Backbone, _, gettext, BaseModal, date, XBlockViewUtils, DateUtils, ViewUtils - ) { - 'use strict'; - var EditSectionXBlockModal, BaseDateView, ReleaseDateView, DueDateView, - GradingView, StaffLockView; - - EditSectionXBlockModal = BaseModal.extend({ - events : { - 'click .action-save': 'save', - 'click .action-modes a': 'changeMode' - }, - - options: $.extend({}, BaseModal.prototype.options, { - modalName: 'edit-outline-item', - modalType: 'edit-settings', - addSaveButton: true, - modalSize: 'med', - viewSpecificClasses: 'confirm' - }), - - initialize: function() { - BaseModal.prototype.initialize.call(this); - this.events = _.extend({}, BaseModal.prototype.events, this.events); - this.template = this.loadTemplate('edit-outline-item-modal'); - this.options.title = this.getTitle(); - this.initializeComponents(); - }, - - getTitle: function () { - return _.template( - gettext('<%= sectionName %> Settings'), - { sectionName: this.model.get('display_name') } - ); - }, - - getContentHtml: function() { - return this.template(this.getContext()); - }, - - afterRender: function() { - BaseModal.prototype.render.apply(this, arguments); - this.invokeComponentMethod('afterRender'); - }, - - save: function(event) { - event.preventDefault(); - var requestData = _.extend({}, this.getRequestData(), { - metadata: this.getMetadata() - }); - // Only update if something changed to prevent items from erroneously entering draft state - if (!_.isEqual(requestData, { metadata: {} })) { - XBlockViewUtils.updateXBlockFields(this.model, requestData, { - success: this.options.onSave - }); - } - this.hide(); - }, - - /** - * Call the method on each value in the list. If the element of the - * list doesn't have such a method it will be skipped. - * @param {String} methodName The method name needs to be called. - * @return {Object} - */ - invokeComponentMethod: function (methodName) { - var values = _.map(this.components, function (component) { - if (_.isFunction(component[methodName])) { - return component[methodName].call(component); - } - }); - - return _.extend.apply(this, [{}].concat(values)); - }, - - /** - * Return context for the modal. - * @return {Object} - */ - getContext: function () { - return _.extend({ - xblockInfo: this.model, - xblockType: XBlockViewUtils.getXBlockType(this.model.get('category'), this.parentInfo, true) - }, this.invokeComponentMethod('getContext')); - }, - - /** - * Return request data. - * @return {Object} - */ - getRequestData: function () { - return this.invokeComponentMethod('getRequestData'); - }, - - /** - * Return metadata for the XBlock. - * @return {Object} - */ - getMetadata: function () { - return this.invokeComponentMethod('getMetadata'); - }, - - /** - * Initialize internal components. - */ - initializeComponents: function () { - this.components = []; - this.components.push( - new StaffLockView({ - selector: '.edit-staff-lock', - parentView: this, - model: this.model - }) - ); - - if (this.model.isChapter() || this.model.isSequential()) { - this.components.push( - new ReleaseDateView({ - selector: '.scheduled-date-input', - parentView: this, - model: this.model - }) - ); - } - - if (this.model.isSequential()) { - this.components.push( - new DueDateView({ - selector: '.due-date-input', - parentView: this, - model: this.model - }), - new GradingView({ - selector: '.edit-settings-grading', - parentView: this, - model: this.model - }) - ); - } - } - }); - - BaseDateView = Backbone.View.extend({ - // Attribute name in the model, should be defined in children classes. - fieldName: null, - - events : { - 'click .clear-date': 'clearValue' - }, - - afterRender: function () { - this.setElement(this.options.parentView.$(this.options.selector).get(0)); - this.$('input.date').datepicker({'dateFormat': 'm/d/yy'}); - this.$('input.time').timepicker({ - 'timeFormat' : 'H:i', - 'forceRoundTime': true - }); - if (this.model.get(this.fieldName)) { - DateUtils.setDate( - this.$('input.date'), this.$('input.time'), - this.model.get(this.fieldName) - ); - } - } - }); - - DueDateView = BaseDateView.extend({ - fieldName: 'due', - - getValue: function () { - return DateUtils.getDate(this.$('#due_date'), this.$('#due_time')); - }, - - clearValue: function (event) { - event.preventDefault(); - this.$('#due_time, #due_date').val(''); - }, - - getMetadata: function () { - return { - 'due': this.getValue() - }; - } - }); - - ReleaseDateView = BaseDateView.extend({ - fieldName: 'start', - startingReleaseDate: null, - - afterRender: function () { - BaseDateView.prototype.afterRender.call(this); - // Store the starting date and time so that we can determine if the user - // actually changed it when "Save" is pressed. - this.startingReleaseDate = this.getValue(); - }, - - getValue: function () { - return DateUtils.getDate(this.$('#start_date'), this.$('#start_time')); - }, - - clearValue: function (event) { - event.preventDefault(); - this.$('#start_time, #start_date').val(''); - }, - - getMetadata: function () { - var newReleaseDate = this.getValue(); - if (JSON.stringify(newReleaseDate) === JSON.stringify(this.startingReleaseDate)) { - return {}; - } - return { - 'start': newReleaseDate - }; - } - }); - - GradingView = Backbone.View.extend({ - afterRender: function () { - this.setElement(this.options.parentView.$(this.options.selector).get(0)); - this.setValue(this.model.get('format')); - }, - - setValue: function (value) { - this.$('#grading_type').val(value); - }, - - getValue: function () { - return this.$('#grading_type').val(); - }, - - getRequestData: function () { - return { - 'graderType': this.getValue() - }; - }, - - getContext: function () { - return { - graderTypes: JSON.parse(this.model.get('course_graders')) - }; - } - }); - - StaffLockView = Backbone.View.extend({ - isModelLocked: function() { - return this.model.get('has_explicit_staff_lock'); - }, - - isAncestorLocked: function() { - return this.model.get('ancestor_has_staff_lock'); - }, - - afterRender: function () { - this.setElement(this.options.parentView.$(this.options.selector).get(0)); - this.setLock(this.isModelLocked()); - }, - - setLock: function(value) { - this.$('#staff_lock').prop('checked', value); - }, - - isLocked: function() { - return this.$('#staff_lock').is(':checked'); - }, - - hasChanges: function() { - return this.isModelLocked() != this.isLocked(); - }, - - getRequestData: function() { - return this.hasChanges() ? { publish: 'republish' } : {}; - }, - - getMetadata: function() { - // Setting visible_to_staff_only to null when disabled will delete the field from this - // xblock, allowing it to inherit the value of its ancestors. - return this.hasChanges() ? { visible_to_staff_only: this.isLocked() ? true : null } : {}; - }, - - getContext: function () { - return { - hasExplicitStaffLock: this.isModelLocked(), - ancestorLocked: this.isAncestorLocked() - } - } - }); - - return EditSectionXBlockModal; - }); diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss index 907ba57cfc..ab6ff45cda 100644 --- a/cms/static/sass/elements/_modal-window.scss +++ b/cms/static/sass/elements/_modal-window.scss @@ -234,8 +234,11 @@ } } - // edit outline item settings - .edit-outline-item-modal { + // outline: edit item settings + .wrapper-modal-window-bulkpublish-section, + .wrapper-modal-window-bulkpublish-subsection, + .wrapper-modal-window-bulkpublish-unit, + .course-outline-modal { .list-fields { diff --git a/cms/static/sass/elements/_modules.scss b/cms/static/sass/elements/_modules.scss index 9894fffcab..0fa5092801 100644 --- a/cms/static/sass/elements/_modules.scss +++ b/cms/static/sass/elements/_modules.scss @@ -263,7 +263,6 @@ // outline UI // -------------------- - // outline: utilities $outline-indent-width: $baseline; @@ -294,82 +293,20 @@ $outline-indent-width: $baseline; } -// UI: section -%outline-section { - @include transition(border-left-width $tmg-f2 linear 0s, border-left-color $tmg-f2 linear 0s, padding-left $tmg-f2 linear 0s); - border-left: 1px solid $color-draft; - margin-bottom: $baseline; - padding: ($baseline/4) ($baseline/2) ($baseline/2) ($baseline/2); +%outline-item-status { + @extend %t-copy-sub2; + @extend %t-strong; + color: $color-copy-base; - // STATE: is-collapsed - &.is-collapsed { - border-left-width: ($baseline/4); - padding-left: $baseline; - - // CASE: is ready to be live - &.is-ready { - border-left-color: $color-ready; - } - - // CASE: is live - &.is-live { - border-left-color: $color-live; - } - - // CASE: has staff-only content - &.is-staff-only { - border-left-color: $color-staff-only; - } - - // CASE: has unpublished content - &.has-warnings { - border-left-color: $color-warning; - } - - // CASE: has errors - &.has-errors { - border-left-color: $color-error; - } + .icon { + @extend %t-icon5; + margin-right: ($baseline/4); } } -// UI: subsection -%outline-subsection { - @include transition(border-left-color $tmg-f2 linear 0s); - margin-bottom: ($baseline/2); - border: 1px solid $gray-l4; - border-left: ($baseline/4) solid $color-draft; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - padding: ($baseline/4) ($baseline/2) ($baseline/2) ($baseline/2); - - // CASE: is ready to be live - &.is-ready { - border-left-color: $color-ready; - } - - // CASE: is live - &.is-live { - border-left-color: $color-live; - } - - // CASE: is presented for staff only - &.is-staff-only { - border-left-color: $color-staff-only; - } - - // CASE: has unpublished content - &.has-warnings { - border-left-color: $color-warning; - } - - // CASE: has errors - &.has-errors { - border-left-color: $color-error; - } -} - -%outline-item { +// outline UI - complex +// -------------------- +%outline-complex-item { // UI: item title .item-title { @@ -424,154 +361,326 @@ $outline-indent-width: $baseline; } } -%outline-item-status { - @extend %t-copy-sub2; - @extend %t-strong; - color: $color-copy-base; - .icon { - @extend %t-icon5; - margin-right: ($baseline/4); - } -} - -// outline: sections -.outline-section { - @extend %ui-window; - @extend %outline-item; - @extend %outline-section; - - // header - title - .section-title { - @extend %t-title5; - @extend %t-strong; - color: $color-heading-base; - } - - // status - .section-status { - @extend %outline-item-status; - } - - // status - release - .status-release { - @include transition(opacity $tmg-f2 ease-in-out 0s); - opacity: 0.65; - } - - // status - grading - .status-grading { - @include transition(opacity $tmg-f2 ease-in-out 0s); - opacity: 0.65; - } - - .status-grading-value { - display: inline-block; - vertical-align: middle; - } - - .status-grading-date { - display: inline-block; - vertical-align: middle; - margin-left: ($baseline/4); - } - - // status - message - .status-message { - margin-top: ($baseline/2); - border-top: 1px solid $gray-l4; - padding-top: ($baseline/4); - - .icon { - margin-right: ($baseline/4); - } - } - - .status-message-copy { - display: inline-block; - color: $color-heading-base; - } - - // STATE: hover/active - &:hover, &:active { - - // status - release - > .section-status .status-release { - opacity: 1.0; - } - } -} - -// outline: subsections -.outline-subsection { - @extend %outline-item; - @extend %outline-subsection; +// outline UI - simple +// -------------------- +%outline-simple-item { border: 1px solid $gray-l4; - border-left: ($baseline/4) solid $color-draft; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - padding: ($baseline*0.75); - // STATE: hover/active - &:hover, &:active { - box-shadow: 0 1px 1px $shadow-l2; + // CASE: last-child in UI + &:last-child { + margin-bottom: 0; } - // STATE: is-collapsed - &.is-collapsed { + .item-title a { + color: $color-heading-base; + &:hover { + color: $blue; + } } +} - // header - title - .subsection-title { - @extend %t-title6; - color: $color-heading-base; - } - // status - .subsection-status { - @extend %outline-item-status; - } +// CASE: complex outline +.outline-complex { - // STATE: hover/active - &:hover, &:active { + // outline: sections + .outline-section { + @include transition(border-left-width $tmg-f2 linear 0s, border-left-color $tmg-f2 linear 0s, padding-left $tmg-f2 linear 0s); + @extend %ui-window; + @extend %outline-complex-item; + border-left: 1px solid $color-draft; + margin-bottom: $baseline; + padding: ($baseline/4) ($baseline/2) ($baseline/2) ($baseline/2); + + // STATE: is-collapsed + &.is-collapsed { + border-left-width: ($baseline/4); + padding-left: $baseline; + + // CASE: is ready to be live + &.is-ready { + border-left-color: $color-ready; + } + + // CASE: is live + &.is-live { + border-left-color: $color-live; + } + + // CASE: has staff-only content + &.is-staff-only { + border-left-color: $color-staff-only; + } + + // CASE: has unpublished content + &.has-warnings { + border-left-color: $color-warning; + } + + // CASE: has errors + &.has-errors { + border-left-color: $color-error; + } + } + + // header - title + .section-title { + @extend %t-title5; + @extend %t-strong; + color: $color-heading-base; + } + + // status + .section-status { + @extend %outline-item-status; + } // status - release - > .subsection-status .status-release { - opacity: 1.0; + .status-release { + @include transition(opacity $tmg-f2 ease-in-out 0s); + opacity: 0.65; + } + + // status - message + .status-message { + margin-top: ($baseline/2); + border-top: 1px solid $gray-l4; + padding-top: ($baseline/4); + + .icon { + margin-right: ($baseline/4); + } + } + + .status-message-copy { + display: inline-block; + color: $color-heading-base; + } + + // STATE: hover/active + &:hover, &:active { + + // status - release + > .section-status .status-release { + opacity: 1.0; + } + } + } + + // outline: subsections + .outline-subsection { + @include transition(border-left-color $tmg-f2 linear 0s); + @extend %outline-complex-item; + margin-bottom: ($baseline/2); + border: 1px solid $gray-l4; + border-left: ($baseline/4) solid $color-draft; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + padding: ($baseline*0.75); + + + // CASE: is ready to be live + &.is-ready { + border-left-color: $color-ready; + } + + // CASE: is live + &.is-live { + border-left-color: $color-live; + } + + // CASE: is presented for staff only + &.is-staff-only { + border-left-color: $color-staff-only; + } + + // CASE: has unpublished content + &.has-warnings { + border-left-color: $color-warning; + } + + // CASE: has errors + &.has-errors { + border-left-color: $color-error; + } + + // STATE: hover/active + &:hover, &:active { + box-shadow: 0 1px 1px $shadow-l2; + } + + // STATE: is-collapsed + &.is-collapsed { + + } + + // header - title + .subsection-title { + @extend %t-title6; + color: $color-heading-base; + } + + // status + .subsection-status { + @extend %outline-item-status; + } + + // STATE: hover/active + &:hover, &:active { + + // status - release + > .subsection-status .status-release { + opacity: 1.0; + } + + // status - grading + > .subsection-status .status-grading { + opacity: 1.0; + } } // status - grading - > .subsection-status .status-grading { - opacity: 1.0; + .status-grading { + @include transition(opacity $tmg-f2 ease-in-out 0s); + opacity: 0.65; + } + + .status-grading-value { + display: inline-block; + vertical-align: middle; + } + + .status-grading-date { + display: inline-block; + vertical-align: middle; + margin-left: ($baseline/4); + } + } + + // outline: units + .outline-unit { + @extend %outline-complex-item; + margin-bottom: ($baseline/2); + border: 1px solid $gray-l4; + padding: ($baseline/4) ($baseline/2); + + // header - title + .unit-title { + @extend %t-title7; + color: $color-heading-base; + } + + .unit-status { + @extend %outline-item-status; + } + + // STATE: hover/active + &:hover, &:active { + box-shadow: 0 1px 1px $shadow-l2; + + // status - release + .unit-status .status-release { + opacity: 1.0; + } } } } -// outline: units -.outline-unit { - @extend %outline-item; - margin-bottom: ($baseline/2); - border: 1px solid $gray-l4; - padding: ($baseline/4) ($baseline/2); +// CASE: simple outline +.outline-simple { - // header - title - .unit-title { - @extend %t-title7; - color: $color-heading-base; - } + // outline: sections + .outline-section { + @extend %outline-simple-item; + margin-bottom: $baseline; + padding: ($baseline/2); - .unit-status { - @extend %outline-item-status; - } + // header - title + .section-title { + @extend %t-title5; + @extend %t-strong; + color: $color-heading-base; + } - // STATE: hover/active - &:hover, &:active { - box-shadow: 0 1px 1px $shadow-l2; + // status + .section-status { + @extend %outline-item-status; + } // status - release - .unit-status .status-release { - opacity: 1.0; + .status-release { + @include transition(opacity $tmg-f2 ease-in-out 0s); + opacity: 0.65; + } + + // status - grading + .status-grading { + @include transition(opacity $tmg-f2 ease-in-out 0s); + opacity: 0.65; + } + + .status-grading-value { + display: inline-block; + vertical-align: middle; + } + + .status-grading-date { + display: inline-block; + vertical-align: middle; + margin-left: ($baseline/4); + } + + // status - message + .status-message { + margin-top: ($baseline/2); + border-top: 1px solid $gray-l4; + padding-top: ($baseline/4); + + .icon { + margin-right: ($baseline/4); + } + } + + .status-message-copy { + display: inline-block; + color: $color-heading-base; + } + } + + // outline: subsections + .outline-subsection { + @extend %outline-simple-item; + margin-bottom: ($baseline/2); + padding: ($baseline/2); + + // header - title + .subsection-title { + @extend %t-title6; + color: $color-heading-base; + } + + // status + .subsection-status { + @extend %outline-item-status; + } + } + + // outline: units + .outline-unit { + @extend %outline-simple-item; + margin-bottom: ($baseline/4); + padding: ($baseline/4) ($baseline/2); + + // header - title + .unit-title { + @extend %t-title7; + color: $color-heading-base; + } + + .unit-status { + @extend %outline-item-status; } } } diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss index 4ee19dee2a..7a2163939c 100644 --- a/cms/static/sass/views/_container.scss +++ b/cms/static/sass/views/_container.scss @@ -259,34 +259,10 @@ } .wrapper-unit-tree-location { - // tree location-specific styles should go here - - .outline-section{ - box-shadow: none; - border: 0; - padding: 0; - } - - .outline-subsection { - border-left: 1px solid $gray-l4; - padding: ($baseline/2); - - .subsection-header { - margin-bottom: ($baseline/2); - } - } .item-title { @extend %cont-text-wrap; } - - .item-title a { - color: $color-heading-base; - - &:hover { - color: $blue; - } - } } } } diff --git a/cms/static/sass/views/_outline.scss b/cms/static/sass/views/_outline.scss index f3b305ad0c..e57569ac45 100644 --- a/cms/static/sass/views/_outline.scss +++ b/cms/static/sass/views/_outline.scss @@ -220,12 +220,271 @@ // outline // -------------------- - .outline { + + // UI: simple version of the outline + .outline-simple { + + } + + // UI: complex version of the outline + .outline-complex { .outline-content { margin-top: 0; } + // outline: items + .outline-item { + + // CASE: expand/collapse-able + &.is-collapsible { + + // only select the current item's toggle expansion controls + &:nth-child(1) .ui-toggle-expansion, &:nth-child(1) .item-title { + + // STATE: hover/active + &:hover, &:active { + color: $blue; + } + } + + &.is-dragging { + @include transition-property(none); + } + } + + // item: title + .item-title { + + // STATE: is-editable + &.is-editable { + + // editor + + .editor { + display: block; + + .item-edit-title { + width: 100%; + } + } + } + } + + // STATE: drag and drop + .drop-target-prepend .draggable-drop-indicator-initial { + opacity: 1.0; + } + + // STATE: was dropped + &.was-dropped { + border-color: $blue; + } + } + + // outline: sections + // -------------------- + .outline-section { + padding: ($baseline*0.75) $baseline ($baseline*0.75) ($baseline + 4); + + // header + .section-header { + @extend %outline-item-header; + + .incontext-editor-input { + @extend %t-strong; + @extend %t-title5; + } + } + + .section-header-details { + float: left; + width: flex-grid(6, 9); + + .icon, .wrapper-section-title { + display: inline-block; + vertical-align: top; + } + + .icon { + margin-right: ($baseline/4); + } + + .wrapper-section-title { + width: flex-grid(5, 6); + line-height: 0; + } + } + + .section-header-actions { + float: right; + width: flex-grid(3, 9); + margin-top: -($baseline/4); + text-align: right; + + .actions-list { + @extend %actions-list; + @extend %t-action2; + } + } + + // in-context actions + .incontext-editor-action-wrapper { + top: -($baseline/20); + } + + // status + .section-status { + margin: 0 0 0 ($outline-indent-width*1.25); + } + + // content + .section-content { + @extend %outline-item-content-shown; + } + + // CASE: is-collapsible + &.is-collapsible { + @extend %ui-expand-collapse; + + .ui-toggle-expansion { + @extend %t-icon3; + color: $gray-l3; + } + } + + // STATE: is-collapsed + &.is-collapsed { + + .section-content { + @extend %outline-item-content-hidden; + } + } + + // STATE: drag and drop - was dropped + &.was-dropped { + border-left-color: $ui-action-primary-color-focus; + } + } + + // outline: subsections + // -------------------- + .list-subsections { + margin: $baseline 0 0 0; + } + + .outline-subsection { + padding: ($baseline*0.75); + + // header + .subsection-header { + @extend %outline-item-header; + + .incontext-editor-input { + @extend %t-title6; + } + } + + .subsection-header-details { + float: left; + width: flex-grid(6, 9); + + .icon, .wrapper-subsection-title { + display: inline-block; + vertical-align: top; + } + + .icon { + margin-right: ($baseline/4); + } + + .wrapper-subsection-title { + width: flex-grid(5, 6); + margin-top: -($baseline/10); + line-height: 0; + } + } + + .subsection-header-actions { + float: right; + width: flex-grid(3, 9); + margin-top: -($baseline/4); + text-align: right; + + .actions-list { + @extend %actions-list; + @extend %t-action2; + margin-right: ($baseline/2); + } + } + + // in-context actions + .incontext-editor-action-wrapper { + top: -($baseline/10); + } + + // status + .subsection-status { + margin: 0 0 0 $outline-indent-width; + } + + // content + .subsection-content { + @extend %outline-item-content-shown; + } + + // CASE: is-collapsible + &.is-collapsible { + @extend %ui-expand-collapse; + + .ui-toggle-expansion { + @extend %t-icon4; + color: $gray-l3; + } + } + + // STATE: is-collapsed + &.is-collapsed { + + .subsection-content { + @extend %outline-item-content-hidden; + } + } + } + + // outline: units + // -------------------- + .list-units { + margin: $baseline 0 0 0; + } + + .outline-unit { + @include transition(margin $tmg-f2 linear 0s); // needed for drag and drop transitions + margin-left: $outline-indent-width; + + // header + .unit-header { + @extend %outline-item-header; + } + + .unit-header-details { + float: left; + width: flex-grid(6, 9); + margin-top: ($baseline/4); + } + + .unit-header-actions { + float: right; + width: flex-grid(3, 9); + margin-top: -($baseline/10); + text-align: right; + + .actions-list { + @extend %actions-list; + @extend %t-action2; + } + } + } + // add/new items .add-item { margin-top: ($baseline*0.75); @@ -256,258 +515,6 @@ } } - // outline: items - .outline-item { - - // CASE: expand/collapse-able - &.is-collapsible { - - // only select the current item's toggle expansion controls - &:nth-child(1) .ui-toggle-expansion, &:nth-child(1) .item-title { - - // STATE: hover/active - &:hover, &:active { - color: $blue; - } - } - - &.is-dragging { - @include transition-property(none); - } - } - - // item: title - .item-title { - - // STATE: is-editable - &.is-editable { - - // editor - + .editor { - display: block; - - .item-edit-title { - width: 100%; - } - } - } - } - - // STATE: drag and drop - .drop-target-prepend .draggable-drop-indicator-initial { - opacity: 1.0; - } - - // STATE: was dropped - &.was-dropped { - border-color: $blue; - } - } - - // outline: sections - // -------------------- - .outline-section { - padding: ($baseline*0.75) $baseline ($baseline*0.75) ($baseline + 4); - - // header - .section-header { - @extend %outline-item-header; - - .incontext-editor-input { - @extend %t-strong; - @extend %t-title5; - } - } - - .section-header-details { - float: left; - width: flex-grid(6, 9); - - .icon, .wrapper-section-title { - display: inline-block; - vertical-align: top; - } - - .icon { - margin-right: ($baseline/4); - } - - .wrapper-section-title { - width: flex-grid(5, 6); - line-height: 0; - } - } - - .section-header-actions { - float: right; - width: flex-grid(3, 9); - margin-top: -($baseline/4); - text-align: right; - - .actions-list { - @extend %actions-list; - @extend %t-action2; - } - } - - // in-context actions - .incontext-editor-action-wrapper { - top: -($baseline/20); - } - - // status - .section-status { - margin: 0 0 0 ($outline-indent-width*1.25); - } - - // content - .section-content { - @extend %outline-item-content-shown; - } - - // CASE: is-collapsible - &.is-collapsible { - @extend %ui-expand-collapse; - - .ui-toggle-expansion { - @extend %t-icon3; - color: $gray-l3; - } - } - - // STATE: is-collapsed - &.is-collapsed { - - .section-content { - @extend %outline-item-content-hidden; - } - } - - // STATE: drag and drop - was dropped - &.was-dropped { - border-left-color: $ui-action-primary-color-focus; - } - } - - // outline: subsections - // -------------------- - .list-subsections { - margin: $baseline 0 0 0; - } - - .outline-subsection { - padding: ($baseline*0.75); - - // header - .subsection-header { - @extend %outline-item-header; - - .incontext-editor-input { - @extend %t-title6; - } - } - - .subsection-header-details { - float: left; - width: flex-grid(6, 9); - - .icon, .wrapper-subsection-title { - display: inline-block; - vertical-align: top; - } - - .icon { - margin-right: ($baseline/4); - } - - .wrapper-subsection-title { - width: flex-grid(5, 6); - margin-top: -($baseline/10); - line-height: 0; - } - } - - .subsection-header-actions { - float: right; - width: flex-grid(3, 9); - margin-top: -($baseline/4); - text-align: right; - - .actions-list { - @extend %actions-list; - @extend %t-action2; - margin-right: ($baseline/2); - } - } - - // in-context actions - .incontext-editor-action-wrapper { - top: -($baseline/10); - } - - // status - .subsection-status { - margin: 0 0 0 $outline-indent-width; - } - - // content - .subsection-content { - @extend %outline-item-content-shown; - } - - // CASE: is-collapsible - &.is-collapsible { - @extend %ui-expand-collapse; - - .ui-toggle-expansion { - @extend %t-icon4; - color: $gray-l3; - } - } - - // STATE: is-collapsed - &.is-collapsed { - - .subsection-content { - @extend %outline-item-content-hidden; - } - } - } - - // outline: units - // -------------------- - .list-units { - margin: $baseline 0 0 0; - } - - .outline-unit { - @include transition(margin $tmg-f2 linear 0s); // needed for drag and drop transitions - margin-left: $outline-indent-width; - - // header - .unit-header { - @extend %outline-item-header; - } - - .unit-header-details { - float: left; - width: flex-grid(6, 9); - margin-top: ($baseline/4); - } - - .unit-header-actions { - float: right; - width: flex-grid(3, 9); - margin-top: -($baseline/10); - text-align: right; - - .actions-list { - @extend %actions-list; - @extend %t-action2; - } - } - } - // UI: drag and drop: section // -------------------- @@ -587,4 +594,147 @@ bottom: -($baseline/2); } } + + // outline: edit item settings + .wrapper-modal-window-bulkpublish-section, + .wrapper-modal-window-bulkpublish-subsection, + .wrapper-modal-window-bulkpublish-unit, + .course-outline-modal { + + .list-fields { + + .field { + display: inline-block; + vertical-align: top; + margin-right: ($baseline/2); + margin-bottom: ($baseline/4); + + + // TODO: refactor the _forms.scss partial to allow for this area to inherit from it + label, input, textarea { + display: block; + } + + label { + @extend %t-copy-sub1; + @include transition(color $tmg-f3 ease-in-out 0s); + margin: 0 0 ($baseline/4) 0; + font-weight: 600; + + &.is-focused { + color: $blue; + } + } + + + input, textarea { + @extend %t-copy-base; + @include transition(all $tmg-f2 ease-in-out 0s); + height: 100%; + width: 100%; + padding: ($baseline/2); + + // CASE: long length + &.long { + width: 100%; + } + + // CASE: short length + &.short { + width: 25%; + } + } + + // CASE: specific release + due times/dates + .start-date, + .start-time, + .due-date, + .due-time { + width: ($baseline*7); + } + } + + // CASE: select input + .field-select { + + .label, .input { + display: inline-block; + vertical-align: middle; + } + + .label { + margin-right: ($baseline/2); + } + + .input { + width: 100%; + } + } + } + + .edit-settings-grading { + + .grading-type { + margin-bottom: $baseline; + } + } + } + + // outline: bulk publishing items + .bulkpublish-section-modal, + .bulkpublish-subsection-modal, + .bulkpublish-unit-modal { + + .modal-introduction { + color: $gray-d2; + } + + .modal-section .outline-bulkpublish { + max-height: ($baseline*20); + overflow-y: auto; + } + + .outline-section, + .outline-subsection { + border: none; + padding: 0; + } + + .outline-subsection { + margin-bottom: $baseline; + padding-right: ($baseline/4); + } + + .outline-subsection .subsection-title { + @extend %t-title8; + margin-bottom: ($baseline/4); + font-weight: 600; + color: $gray-l2; + text-transform: uppercase; + } + + .outline-unit .unit-title, .outline-unit .unit-status { + display: inline-block; + vertical-align: middle; + } + + .outline-unit .unit-title { + @extend %t-title7; + color: $color-heading-base; + } + + .outline-unit .unit-status { + @extend %t-copy-sub2; + text-align: right; + } + + } + + // it is the only element there + .bulkpublish-unit-modal { + .modal-introduction { + margin-bottom: 0; + } + } + } diff --git a/cms/templates/container.html b/cms/templates/container.html index 4610acfa48..3cb25af477 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -144,7 +144,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
${_("Location in Course Outline")}
-
+
diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index f3ffe0e6b6..ee22386600 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -29,7 +29,7 @@ from contentstore.utils import reverse_usage_url <%block name="header_extras"> -% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'edit-outline-item-modal']: +% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor']: @@ -91,7 +91,7 @@ from contentstore.utils import reverse_usage_url course_locator = context_course.location %>

${_("Course Outline")}

-
+
@@ -116,5 +116,4 @@ from contentstore.utils import reverse_usage_url
-
diff --git a/cms/templates/js/course-outline-modal.underscore b/cms/templates/js/course-outline-modal.underscore new file mode 100644 index 0000000000..51a2d79f13 --- /dev/null +++ b/cms/templates/js/course-outline-modal.underscore @@ -0,0 +1,7 @@ +
+ + +
+ diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore index f1ba753da7..d38f1ff40a 100644 --- a/cms/templates/js/course-outline.underscore +++ b/cms/templates/js/course-outline.underscore @@ -1,5 +1,4 @@ <% -var category = xblockInfo.get('category'); var releasedToStudents = xblockInfo.get('released_to_students'); var visibilityState = xblockInfo.get('visibility_state'); var published = xblockInfo.get('published'); @@ -10,7 +9,7 @@ if (staffOnlyMessage) { statusType = 'staff-only'; statusMessage = gettext('Contains staff only content'); } else if (visibilityState === 'needs_attention') { - if (category === 'vertical') { + if (xblockInfo.isVertical()) { statusType = 'warning'; if (published && releasedToStudents) { statusMessage = gettext('Unpublished changes to live content'); @@ -63,6 +62,14 @@ if (xblockInfo.get('graded')) {
- <% } else if (category !== 'vertical') { %> + <% } else if (!xblockInfo.isVertical()) { %>
  1. diff --git a/cms/templates/js/due-date-editor.underscore b/cms/templates/js/due-date-editor.underscore new file mode 100644 index 0000000000..a1200f8ca0 --- /dev/null +++ b/cms/templates/js/due-date-editor.underscore @@ -0,0 +1,22 @@ +
      +
    • + + +
    • + +
    • + + +
    • +
    + + diff --git a/cms/templates/js/edit-outline-item-modal.underscore b/cms/templates/js/edit-outline-item-modal.underscore deleted file mode 100644 index a7811dfc74..0000000000 --- a/cms/templates/js/edit-outline-item-modal.underscore +++ /dev/null @@ -1,125 +0,0 @@ -
    - - -
    - <% if (xblockInfo.isChapter() || xblockInfo.isSequential()) { %> - - <% } %> - - <% if (xblockInfo.isSequential()) { %> - - <% } %> - - -
    -
    diff --git a/cms/templates/js/grading-editor.underscore b/cms/templates/js/grading-editor.underscore new file mode 100644 index 0000000000..7db05aa5ff --- /dev/null +++ b/cms/templates/js/grading-editor.underscore @@ -0,0 +1,14 @@ + + diff --git a/cms/templates/js/publish-editor.underscore b/cms/templates/js/publish-editor.underscore new file mode 100644 index 0000000000..27c4ce2902 --- /dev/null +++ b/cms/templates/js/publish-editor.underscore @@ -0,0 +1,38 @@ +<% if (!xblockInfo.isVertical()) { %> + +<% } %> diff --git a/cms/templates/js/release-date-editor.underscore b/cms/templates/js/release-date-editor.underscore new file mode 100644 index 0000000000..75e70c7d25 --- /dev/null +++ b/cms/templates/js/release-date-editor.underscore @@ -0,0 +1,28 @@ + + diff --git a/cms/templates/js/staff-lock-editor.underscore b/cms/templates/js/staff-lock-editor.underscore new file mode 100644 index 0000000000..67074c0647 --- /dev/null +++ b/cms/templates/js/staff-lock-editor.underscore @@ -0,0 +1,35 @@ +
    + + +
    \ No newline at end of file diff --git a/cms/templates/ux/reference/modal_bulkpublish-section.html b/cms/templates/ux/reference/modal_bulkpublish-section.html new file mode 100644 index 0000000000..ee5e548a72 --- /dev/null +++ b/cms/templates/ux/reference/modal_bulkpublish-section.html @@ -0,0 +1,143 @@ +<%! from django.utils.translation import ugettext as _ %> + + diff --git a/cms/templates/ux/reference/modal_bulkpublish-subsection.html b/cms/templates/ux/reference/modal_bulkpublish-subsection.html new file mode 100644 index 0000000000..980cbc9933 --- /dev/null +++ b/cms/templates/ux/reference/modal_bulkpublish-subsection.html @@ -0,0 +1,73 @@ +<%! from django.utils.translation import ugettext as _ %> + + diff --git a/cms/templates/ux/reference/modal_bulkpublish-unit.html b/cms/templates/ux/reference/modal_bulkpublish-unit.html new file mode 100644 index 0000000000..8106aa5255 --- /dev/null +++ b/cms/templates/ux/reference/modal_bulkpublish-unit.html @@ -0,0 +1,30 @@ +<%! from django.utils.translation import ugettext as _ %> + + diff --git a/common/test/acceptance/pages/studio/overview.py b/common/test/acceptance/pages/studio/overview.py index 2250fd7d03..fc57322727 100644 --- a/common/test/acceptance/pages/studio/overview.py +++ b/common/test/acceptance/pages/studio/overview.py @@ -77,7 +77,7 @@ class CourseOutlineItem(object): @property def has_staff_lock_warning(self): - """ Returns True iff the 'Contains staff only content' message is visible """ + """ Returns True if the 'Contains staff only content' message is visible """ return self.status_message == 'Contains staff only content' if self.has_status_message else False @property @@ -149,6 +149,22 @@ class CourseOutlineItem(object): element = self.q(css=self._bounded_selector(".status-grading-value")) return element.first.text[0] if element.present else None + def publish(self): + """ + Publish the unit. + """ + click_css(self, self._bounded_selector('.action-publish'), require_notification=False) + modal = CourseOutlineModal(self) + EmptyPromise(lambda: modal.is_shown(), 'Modal is shown.') + modal.publish() + + @property + def publish_action(self): + """ + Returns the link for publishing a unit. + """ + return self.q(css=self._bounded_selector('.action-publish')).first + class CourseOutlineContainer(CourseOutlineItem): """ @@ -483,7 +499,7 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): class CourseOutlineModal(object): - MODAL_SELECTOR = ".edit-outline-item-modal" + MODAL_SELECTOR = ".wrapper-modal-window" def __init__(self, page): self.page = page @@ -507,6 +523,10 @@ class CourseOutlineModal(object): self.click(".action-save") self.page.wait_for_ajax() + def publish(self): + self.click(".action-publish") + self.page.wait_for_ajax() + def cancel(self): self.click(".action-cancel") diff --git a/common/test/acceptance/tests/test_studio_outline.py b/common/test/acceptance/tests/test_studio_outline.py index 6d6a6cc7fd..4575971022 100644 --- a/common/test/acceptance/tests/test_studio_outline.py +++ b/common/test/acceptance/tests/test_studio_outline.py @@ -11,6 +11,7 @@ from bok_choy.promise import EmptyPromise from ..pages.studio.overview import CourseOutlinePage, ContainerPage, ExpandCollapseLinkState from ..pages.studio.utils import add_discussion from ..pages.lms.courseware import CoursewarePage +from ..pages.lms.course_nav import CourseNavPage from ..pages.lms.staff_view import StaffPage from ..fixtures.course import XBlockFixtureDesc @@ -1369,3 +1370,129 @@ class UnitNavigationTest(CourseOutlineTest): self.course_outline_page.section_at(0).subsection_at(0).toggle_expand() unit = self.course_outline_page.section_at(0).subsection_at(0).unit_at(0).go_to() self.assertTrue(unit.is_browser_on_page) + + +@attr('shard_1') +class PublishSectionTest(CourseOutlineTest): + """ + Feature: Publish sections. + """ + + __test__ = True + + def populate_course_fixture(self, course_fixture): + """ + Sets up a course structure with 2 subsections inside a single section. + The first subsection has 2 units, and the second subsection has one unit. + """ + self.courseware = CoursewarePage(self.browser, self.course_id) + self.course_nav = CourseNavPage(self.browser) + course_fixture.add_children( + XBlockFixtureDesc('chapter', SECTION_NAME).add_children( + XBlockFixtureDesc('sequential', SUBSECTION_NAME).add_children( + XBlockFixtureDesc('vertical', UNIT_NAME), + XBlockFixtureDesc('vertical', 'Test Unit 2'), + ), + XBlockFixtureDesc('sequential', 'Test Subsection 2').add_children( + XBlockFixtureDesc('vertical', 'Test Unit 3'), + ), + ), + ) + + def test_unit_publishing(self): + """ + Scenario: Can publish a unit and see published content in LMS + Given I have a section with 2 subsections and 3 unpublished units + When I go to the course outline + Then I see publish button for the first unit, subsection, section + When I publish the first unit + Then I see that publish button for the first unit disappears + And I see publish buttons for subsection, section + And I see the changed content in LMS + """ + self._add_unpublished_content() + self.course_outline_page.visit() + section, subsection, unit = self._get_items() + self.assertTrue(unit.publish_action) + self.assertTrue(subsection.publish_action) + self.assertTrue(section.publish_action) + unit.publish() + self.assertFalse(unit.publish_action) + self.assertTrue(subsection.publish_action) + self.assertTrue(section.publish_action) + self.courseware.visit() + self.assertEqual(1, self.courseware.num_xblock_components) + + def test_subsection_publishing(self): + """ + Scenario: Can publish a subsection and see published content in LMS + Given I have a section with 2 subsections and 3 unpublished units + When I go to the course outline + Then I see publish button for the unit, subsection, section + When I publish the first subsection + Then I see that publish button for the first subsection disappears + And I see that publish buttons disappear for the child units of the subsection + And I see publish button for section + And I see the changed content in LMS + """ + self._add_unpublished_content() + self.course_outline_page.visit() + section, subsection, unit = self._get_items() + self.assertTrue(unit.publish_action) + self.assertTrue(subsection.publish_action) + self.assertTrue(section.publish_action) + self.course_outline_page.section(SECTION_NAME).subsection(SUBSECTION_NAME).publish() + self.assertFalse(unit.publish_action) + self.assertFalse(subsection.publish_action) + self.assertTrue(section.publish_action) + self.courseware.visit() + self.assertEqual(1, self.courseware.num_xblock_components) + self.course_nav.go_to_sequential_position(2) + self.assertEqual(1, self.courseware.num_xblock_components) + + def test_section_publishing(self): + """ + Scenario: Can publish a section and see published content in LMS + Given I have a section with 2 subsections and 3 unpublished units + When I go to the course outline + Then I see publish button for the unit, subsection, section + When I publish the section + Then I see that publish buttons disappears + And I see the changed content in LMS + """ + self._add_unpublished_content() + self.course_outline_page.visit() + section, subsection, unit = self._get_items() + self.assertTrue(subsection.publish_action) + self.assertTrue(section.publish_action) + self.assertTrue(unit.publish_action) + self.course_outline_page.section(SECTION_NAME).publish() + self.assertFalse(subsection.publish_action) + self.assertFalse(section.publish_action) + self.assertFalse(unit.publish_action) + self.courseware.visit() + self.assertEqual(1, self.courseware.num_xblock_components) + self.course_nav.go_to_sequential_position(2) + self.assertEqual(1, self.courseware.num_xblock_components) + self.course_nav.go_to_section(SECTION_NAME, 'Test Subsection 2') + self.assertEqual(1, self.courseware.num_xblock_components) + + def _add_unpublished_content(self): + """ + Adds unpublished HTML content to first three units in the course. + """ + for index in xrange(3): + self.course_fixture.create_xblock( + self.course_fixture.get_nested_xblocks(category="vertical")[index].locator, + XBlockFixtureDesc('html', 'Unpublished HTML Component ' + str(index)), + ) + + def _get_items(self): + """ + Returns first section, subsection, and unit on the page. + """ + section = self.course_outline_page.section(SECTION_NAME) + subsection = section.subsection(SUBSECTION_NAME) + unit = subsection.toggle_expand().unit(UNIT_NAME) + + return (section, subsection, unit)