From b49ad93e570a62e2d02854334137cee531cd2ab2 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Tue, 8 Jul 2014 17:16:29 +0300 Subject: [PATCH] Edit section/subsection release dates and grading types. Fix bok_choy test by changing course separator. Change format of the modal title to '[Subsection/Section Name] Settings'. Improve bok_choy test stability. Studio: correcting modal window size name for outline settings editor Specify full date in bok_choy tests. Refactor bok_choy tests. Remove .modal-editor from basic-modal.underscore Set classes in modal window dynamically. Studio: revising outline edit modal tip content and overall size Rename isEditable to isEditableOnCourseOutline. Interpolate display name. Use graded instead of format as flag. Studio: revising outline settings edit modal size Fix selectors in bok_choy tests. --- cms/djangoapps/contentstore/views/item.py | 10 +- .../contentstore/views/tests/test_item.py | 5 + cms/static/coffee/spec/main.coffee | 1 + cms/static/js/base.js | 4 +- cms/static/js/models/xblock_info.js | 92 +++++-- cms/static/js/spec/models/xblock_info_spec.js | 12 + .../spec/views/pages/course_outline_spec.js | 187 +++++++++++++- .../js/utils/{get_date.js => date_utils.js} | 18 +- cms/static/js/views/course_outline.js | 32 ++- cms/static/js/views/modals/base_modal.js | 7 +- .../js/views/modals/edit_outline_item.js | 232 ++++++++++++++++++ cms/static/js/views/modals/edit_xblock.js | 3 +- cms/static/js/views/overview.js | 4 +- cms/static/js/views/uploads.js | 3 +- cms/static/js/views/utils/xblock_utils.js | 27 +- cms/static/sass/elements/_modal-window.scss | 117 +++++++++ cms/templates/course_outline.html | 3 +- cms/templates/js/basic-modal.underscore | 2 +- cms/templates/js/course-outline.underscore | 24 +- .../js/edit-outline-item-modal.underscore | 89 +++++++ common/test/acceptance/pages/lms/progress.py | 4 + .../test/acceptance/pages/studio/overview.py | 138 +++++++++++ .../test/acceptance/tests/base_studio_test.py | 1 - .../acceptance/tests/test_studio_outline.py | 148 ++++++++++- 24 files changed, 1104 insertions(+), 59 deletions(-) create mode 100644 cms/static/js/spec/models/xblock_info_spec.js rename cms/static/js/utils/{get_date.js => date_utils.js} (54%) create mode 100644 cms/static/js/views/modals/edit_outline_item.js create mode 100644 cms/templates/js/edit-outline-item-modal.underscore diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 6b522c2bf49d..85e978c7b09d 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -6,6 +6,7 @@ from uuid import uuid4 from datetime import datetime from pytz import UTC +import json from collections import OrderedDict from functools import partial @@ -642,6 +643,7 @@ def safe_get_username(user_id): release_date = get_default_time_display(xblock.start) if xblock.start != DEFAULT_START_DATE else None published = modulestore().has_item(xblock.location, revision=ModuleStoreEnum.RevisionOption.published_only) + graders = CourseGradingModel.fetch(xblock.location.course_key).graders xblock_info = { "id": unicode(xblock.location), "display_name": xblock.display_name_with_default, @@ -652,7 +654,13 @@ def safe_get_username(user_id): 'studio_url': xblock_studio_url(xblock), "released_to_students": datetime.now(UTC) > xblock.start, "release_date": release_date, - "visibility_state": _compute_visibility_state(xblock, child_info, is_unit_with_changes) if not xblock.category == 'course' else None + "visibility_state": _compute_visibility_state(xblock, child_info, is_unit_with_changes) if not xblock.category == 'course' else None, + "start": xblock.fields['start'].to_json(xblock.start), + "graded": xblock.graded, + "due_date": get_default_time_display(xblock.due), + "due": xblock.fields['due'].to_json(xblock.due), + "format": xblock.format, + "course_graders": json.dumps([grader.get('type') for grader in graders]), } if data is not None: xblock_info["data"] = data diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index 2b97f046f7ab..302373e79e67 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -1173,6 +1173,11 @@ def validate_chapter_xblock_info(self, xblock_info, has_child_info=True): self.assertEqual(xblock_info['display_name'], 'Week 1') self.assertTrue(xblock_info['published']) self.assertIsNone(xblock_info.get('edited_by', None)) + self.assertEqual(xblock_info['course_graders'], '["Homework", "Lab", "Midterm Exam", "Final Exam"]') + self.assertEqual(xblock_info['start'], '2030-01-01T00:00:00Z') + self.assertEqual(xblock_info['graded'], False) + self.assertEqual(xblock_info['due'], None) + self.assertEqual(xblock_info['format'], None) # Finally, validate the entire response for consistency self.validate_xblock_info_consistency(xblock_info, has_child_info=has_child_info) diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index fadb3c2b6ab6..fcca938e2050 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -213,6 +213,7 @@ define([ "js/spec/models/component_template_spec", "js/spec/models/explicit_url_spec", "js/spec/models/group_configuration_spec", + "js/spec/models/xblock_info_spec", "js/spec/utils/drag_and_drop_spec", "js/spec/utils/handle_iframe_binding_spec", diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 7b9c69ee21b6..12ca7b7c877b 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -1,5 +1,5 @@ require(["domReady", "jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt", - "js/utils/get_date", "js/utils/module", "js/utils/handle_iframe_binding", "js/utils/change_on_enter", "jquery.ui", + "js/utils/date_utils", "js/utils/module", "js/utils/handle_iframe_binding", "js/utils/change_on_enter", "jquery.ui", "jquery.leanModal", "jquery.form", "jquery.smoothScroll"], function(domReady, $, _, gettext, NotificationView, PromptView, DateUtils, ModuleUtils, IframeUtils, TriggerChangeEventOnEnter) { @@ -196,7 +196,7 @@ function saveSubsection() { // get datetimes for start and due, stick into metadata _(["start", "due"]).each(function(name) { - var datetime = DateUtils( + var datetime = DateUtils.getDate( document.getElementById(name+"_date"), document.getElementById(name+"_time") ); diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js index 1be5ef7345aa..2bb2cd49e79a 100644 --- a/cms/static/js/models/xblock_info.js +++ b/cms/static/js/models/xblock_info.js @@ -1,4 +1,7 @@ -define(["backbone", "underscore", "js/utils/module"], function(Backbone, _, ModuleUtils) { +define( + ['backbone', 'underscore', 'underscore.string', 'js/utils/module'], +function(Backbone, _, str, ModuleUtils) { + 'use strict'; var XBlockInfo = Backbone.Model.extend({ urlRoot: ModuleUtils.urlRoot, @@ -6,32 +9,32 @@ define(["backbone", "underscore", "js/utils/module"], function(Backbone, _, Modu // NOTE: 'publish' is not an attribute on XBlockInfo, but it is used to signal the publish // and discard changes actions. Therefore 'publish' cannot be introduced as an attribute. defaults: { - "id": null, - "display_name": null, - "category": null, - "data": null, - "metadata" : null, + 'id': null, + 'display_name': null, + 'category': null, + 'data': null, + 'metadata' : null, /** * The Studio URL for this xblock, or null if it doesn't have one. */ - "studio_url": null, + 'studio_url': null, /** * An optional object with information about the children as well as about * the primary xblock type that is supported as a child. */ - "child_info": null, + 'child_info': null, /** * An optional object with information about each of the ancestors. */ - "ancestor_info": null, + 'ancestor_info': null, /** * Date of the last edit to this xblock or any of its descendants. */ - "edited_on":null, + 'edited_on':null, /** * User who last edited the xblock or any of its descendants. */ - "edited_by":null, + 'edited_by':null, /** * True iff a published version of the xblock exists. */ @@ -39,11 +42,11 @@ define(["backbone", "underscore", "js/utils/module"], function(Backbone, _, Modu /** * Date of the last publish of this xblock, or null if never published. */ - "published_on": null, + 'published_on': null, /** * User who last published the xblock, or null if never published. */ - "published_by": null, + 'published_by': null, /** * True if the xblock has changes. * Note: this is not always provided as a performance optimization. It is only provided for @@ -58,23 +61,53 @@ define(["backbone", "underscore", "js/utils/module"], function(Backbone, _, Modu /** * True iff the release date of the xblock is in the past. */ - "released_to_students": null, + 'released_to_students': null, /** * If the xblock is published, the date on which it will be released to students. * This can be null if the release date is unscheduled. */ - "release_date": null, + '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. * This can be null if the release date is unscheduled. */ - "release_date_from":null, + 'release_date_from':null, /** * True if this xblock is currently visible to students. This is computed server-side * so that the logic isn't duplicated on the client. */ - "currently_visible_to_students": null + 'currently_visible_to_students': null, + /** + * If xblock is graded, the date after which student assessment will be evaluated. + * It has same format as release date, for example: 'Jan 02, 2015 at 00:00 UTC'. + */ + 'due_date': null, + /** + * Grading policy for xblock. + */ + 'format': null, + /** + * List of course graders names. + */ + 'course_graders': null, + /** + * True if this xblock contributes to the final course grade. + */ + 'graded': null, + /** + * The same as `release_date` but as an ISO-formatted date string. + */ + 'start': null, + /** + * The same as `due_date` but as an ISO-formatted date string. + */ + 'due': null + }, + + initialize: function () { + // Extend our Model by helper methods. + _.extend(this, this.getCategoryHelpers()); }, parse: function(response) { @@ -100,6 +133,31 @@ define(["backbone", "underscore", "js/utils/module"], function(Backbone, _, Modu hasChildren: function() { var childInfo = this.get('child_info'); return childInfo && childInfo.children.length > 0; + }, + + /** + * Return a list of convenience methods to check affiliation to the category. + * @return {Array} + */ + getCategoryHelpers: function () { + var categories = ['course', 'chapter', 'sequential', 'vertical'], + helpers = {}; + + _.each(categories, function (item) { + helpers['is' + str.titleize(item)] = function () { + return this.get('category') === item; + }; + }, this); + + return helpers; + }, + + /** + * Check if we can edit current XBlock or not on Course Outline page. + * @return {Boolean} + */ + isEditableOnCourseOutline: function() { + return this.isSequential() || this.isChapter(); } }); return XBlockInfo; diff --git a/cms/static/js/spec/models/xblock_info_spec.js b/cms/static/js/spec/models/xblock_info_spec.js new file mode 100644 index 000000000000..3930d5d5df3c --- /dev/null +++ b/cms/static/js/spec/models/xblock_info_spec.js @@ -0,0 +1,12 @@ +define(['backbone', 'js/models/xblock_info'], + function(Backbone, XBlockInfo) { + describe('XblockInfo isEditableOnCourseOutline', function() { + it('works correct', function() { + expect(new XBlockInfo({'category': 'chapter'}).isEditableOnCourseOutline()).toBe(true); + expect(new XBlockInfo({'category': 'course'}).isEditableOnCourseOutline()).toBe(false); + expect(new XBlockInfo({'category': 'sequential'}).isEditableOnCourseOutline()).toBe(true); + expect(new XBlockInfo({'category': 'vertical'}).isEditableOnCourseOutline()).toBe(false); + }); + }); + } +); 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 98d1ac611078..30a7671176f7 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -1,6 +1,6 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers", "js/views/utils/view_utils", - "js/views/pages/course_outline", "js/models/xblock_outline_info"], - function ($, create_sinon, view_helpers, ViewUtils, CourseOutlinePage, XBlockOutlineInfo) { + "js/views/pages/course_outline", "js/models/xblock_outline_info", "js/utils/date_utils", "js/spec_helpers/edit_helpers"], + function ($, create_sinon, view_helpers, ViewUtils, CourseOutlinePage, XBlockOutlineInfo, DateUtils, edit_helpers) { describe("CourseOutlinePage", function() { var createCourseOutlinePage, displayNameInput, model, outlinePage, requests, @@ -56,6 +56,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" published: true, edited_on: 'Jul 02, 2014 at 20:56 UTC', edited_by: 'MockUser', + course_graders: '["Lab", "Howework"]', child_info: { category: 'vertical', display_name: 'Unit', @@ -109,6 +110,9 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" 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'); appendSetFixtures(mockOutlinePage); mockCourseJSON = createMockCourseJSON('mock-course', 'Mock Course', [ createMockSectionJSON('mock-section', 'Mock Section', [ @@ -134,6 +138,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" afterEach(function () { view_helpers.removeMockAnalytics(); + edit_helpers.cancelModalIfShowing(); }); describe('Initial display', function() { @@ -342,15 +347,101 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" expandItemsAndVerifyState('section'); collapseItemsAndVerifyState('section'); }); + + it('can be edited', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + 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(); + + // Section does not contain due_date or grading type selector + expect($("due_date")).not.toExist(); + expect($("grading_format")).not.toExist(); + + $(".edit-outline-item-modal .action-save").click(); + + create_sinon.expectJsonRequest(requests, 'POST', '/xblock/mock-section', { + "metadata":{ + "start":"2015-01-02T00:00:00.000Z", + } + }); + expect(requests[0].requestHeaders['X-HTTP-Method-Override']).toBe('PATCH'); + + // 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' + } + ]) + ]), + { + 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. + create_sinon.respondWithJson(requests, mockResponseSectionJSON); + + expect($(".outline-section .status-release-value")).toContainText("Jan 02, 2015 at 00:00 UTC"); + }); }); describe("Subsection", function() { - var getDisplayNameWrapper; + var getDisplayNameWrapper, setEditModalValues, mockServerValuesJson; getDisplayNameWrapper = function() { return getItemHeaders('subsection').find('.wrapper-xblock-field'); }; + setEditModalValues = function (start_date, due_date, grading_type) { + $("#start_date").val(start_date); + $("#due_date").val(due_date); + $("#grading_type").val(grading_type); + } + + // Contains hard-coded dates because dates are presented in different formats. + var 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' + } + ]) + ]), + { + 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" + }] + } + } + ); + it('can be deleted', function() { var promptSpy = view_helpers.createPromptSpy(); createCourseOutlinePage(this, mockCourseJSON); @@ -409,6 +500,80 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" collapseItemsAndVerifyState('subsection'); expandItemsAndVerifyState('subsection'); }); + + it('can be edited', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + setEditModalValues("7/9/2014", "7/10/2014", "Lab"); + $(".edit-outline-item-modal .action-save").click(); + create_sinon.expectJsonRequest(requests, 'POST', '/xblock/mock-subsection', { + "graderType":"Lab", + "metadata":{ + "start":"2014-07-09T00:00:00.000Z", + "due":"2014-07-10T00:00:00.000Z" + } + }); + expect(requests[0].requestHeaders['X-HTTP-Method-Override']).toBe('PATCH'); + + // This is the response for the change operation. + create_sinon.respondWithJson(requests, {}); + 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. + create_sinon.respondWithJson(requests, mockServerValuesJson); + + expect($(".outline-subsection .status-release-value")).toContainText("Jul 09, 2014 at 00:00 UTC"); + expect($(".outline-subsection .due-date")).toContainText("Due date: Jul 10, 2014 at 00:00 UTC"); + expect($(".outline-subsection .policy")).toContainText("Policy: Lab"); + + expect($(".outline-item .outline-subsection .policy")).toContainText("Policy: Lab"); + outlinePage.$('.outline-item .outline-subsection .configure-button').click(); + expect($("#start_date").val()).toBe('7/9/2014'); + expect($("#due_date").val()).toBe('7/10/2014'); + expect($("#grading_type").val()).toBe('Lab'); + }); + + it('release date, due date and grading type can be cleared.', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-item .outline-subsection .configure-button').click(); + setEditModalValues("7/9/2014", "7/10/2014", "Lab"); + $(".edit-outline-item-modal .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, mockServerValuesJson); + + expect($(".outline-subsection .status-release-value")).toContainText("Jul 09, 2014 at 00:00 UTC"); + expect($(".outline-subsection .due-date")).toContainText("Due date: Jul 10, 2014 at 00:00 UTC"); + expect($(".outline-subsection .policy")).toContainText("Policy: Lab"); + + outlinePage.$('.outline-subsection .configure-button').click(); + expect($("#start_date").val()).toBe('7/9/2014'); + expect($("#due_date").val()).toBe('7/10/2014'); + expect($("#grading_type").val()).toBe('Lab'); + + $(".edit-outline-item-modal .scheduled-date-input .action-clear").click(); + $(".edit-outline-item-modal .due-date-input .action-clear").click(); + expect($("#start_date").val()).toBe(''); + expect($("#due_date").val()).toBe(''); + + $("#grading_type").val('notgraded'); + + $(".edit-outline-item-modal .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', []) + ]) + ); + expect($(".outline-subsection .status-release-value")).not.toContainText("Jul 09, 2014 at 00:00 UTC"); + expect($(".outline-subsection .due-date")).not.toExist(); + expect($(".outline-subsection .policy")).not.toExist(); + }); }); // Note: most tests for units can be found in Bok Choy @@ -434,5 +599,21 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" expect(unitAnchor.attr('href')).toBe('/container/mock-unit'); }); }); + + describe("Date and Time picker", function() { + // Two datetime formats can came from server: '%Y-%m-%dT%H:%M:%SZ' and %Y-%m-%dT%H:%M:%S+TZ:TZ' + it('can parse dates in both formats that can come from server', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + expect($("#start_date").val()).toBe(''); + expect($("#start_time").val()).toBe(''); + DateUtils.setDate($("#start_date"), ("#start_time"), "2015-08-10T05:10:00Z"); + expect($("#start_date").val()).toBe('8/10/2015'); + expect($("#start_time").val()).toBe('05:10'); + DateUtils.setDate($("#start_date"), ("#start_time"), "2014-07-09T00:00:00+00:00"); + expect($("#start_date").val()).toBe('7/9/2014'); + expect($("#start_time").val()).toBe('00:00'); + }); + }); }); }); diff --git a/cms/static/js/utils/get_date.js b/cms/static/js/utils/date_utils.js similarity index 54% rename from cms/static/js/utils/get_date.js rename to cms/static/js/utils/date_utils.js index e934e9aea96c..7af206e91bc8 100644 --- a/cms/static/js/utils/get_date.js +++ b/cms/static/js/utils/date_utils.js @@ -1,4 +1,4 @@ -define(["jquery", "jquery.ui", "jquery.timepicker"], function($) { +define(["jquery", "date", "jquery.ui", "jquery.timepicker"], function($, date) { var getDate = function (datepickerInput, timepickerInput) { // given a pair of inputs (datepicker and timepicker), return a JS Date // object that corresponds to the datetime.js that they represent. Assume @@ -14,5 +14,19 @@ define(["jquery", "jquery.ui", "jquery.timepicker"], function($) { return null; } }; - return getDate; + + var setDate = function (datepickerInput, timepickerInput, datetime) { + // given a pair of inputs (datepicker and timepicker) and the date as an + // ISO-formatted date string. + datetime = date.parse(datetime); + if (datetime) { + $(datepickerInput).datepicker("setDate", datetime); + $(timepickerInput).timepicker("setTime", datetime); + } + }; + + return { + getDate: getDate, + setDate: setDate + }; }); diff --git a/cms/static/js/views/course_outline.js b/cms/static/js/views/course_outline.js index 88f787055d85..adfc1ca365b5 100644 --- a/cms/static/js/views/course_outline.js +++ b/cms/static/js/views/course_outline.js @@ -9,8 +9,9 @@ * - 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"], - function($, _, XBlockOutlineView, ViewUtils, XBlockOutlineInfo) { + "js/models/xblock_outline_info", + "js/views/modals/edit_outline_item"], + function($, _, XBlockOutlineView, ViewUtils, XBlockOutlineInfo, EditSectionXBlockModal) { var CourseOutlineView = XBlockOutlineView.extend({ // takes XBlockOutlineInfo as a model @@ -23,13 +24,12 @@ define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_ return true; } // Only expand the course and its chapters (aka sections) initially - var category = this.model.get('category'); - return category === 'course' || category === 'chapter'; + return this.model.isCourse() || this.model.isChapter(); }, shouldRenderChildren: function() { // Render all nodes up to verticals but not below - return this.model.get('category') !== 'vertical'; + return !this.model.isVertical(); }, createChildView: function(xblockInfo, parentInfo, parentView) { @@ -64,7 +64,7 @@ define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_ var getViewToRefresh, view, expandedLocators; getViewToRefresh = function(view) { - if (view.model.get('category') === 'chapter' || !view.parentView) { + if (view.model.isChapter() || !view.parentView) { return view; } return getViewToRefresh(view.parentView); @@ -119,11 +119,10 @@ define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_ onChildDeleted: function(childView) { var xblockInfo = this.model, - childCategory = childView.model.get('category'), children = xblockInfo.get('child_info') && xblockInfo.get('child_info').children; // If deleting a section that isn't the final one, just remove it for efficiency // as it cannot visually effect the other sections. - if (childCategory === 'chapter' && children && children.length > 1) { + if (childView.model.isChapter() && children && children.length > 1) { childView.$el.remove(); children.splice(children.indexOf(childView.model), 1); } else { @@ -138,6 +137,23 @@ define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_ expanded_locators: [ locator ], scroll_offset: scrollOffset || 0 }; + }, + + editXBlock: function() { + var modal = new EditSectionXBlockModal({ + model: this.model, + onSave: this.refresh.bind(this) + }); + + modal.show(); + }, + + addButtonActions: function(element) { + XBlockOutlineView.prototype.addButtonActions.apply(this, arguments); + element.find('.configure-button').click(function(event) { + event.preventDefault(); + this.editXBlock(); + }.bind(this)); } }); diff --git a/cms/static/js/views/modals/base_modal.js b/cms/static/js/views/modals/base_modal.js index ffd89bdddf6f..eb543295ed6c 100644 --- a/cms/static/js/views/modals/base_modal.js +++ b/cms/static/js/views/modals/base_modal.js @@ -15,7 +15,9 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"], modalName: 'basic', modalType: 'generic', modalSize: 'lg', - title: '' + title: '', + // A list of class names, separated by space. + viewSpecificClasses: '' }), initialize: function() { @@ -39,7 +41,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"], name: this.options.modalName, type: this.options.modalType, size: this.options.modalSize, - title: this.options.title + title: this.options.title, + viewSpecificClasses: this.options.viewSpecificClasses })); this.addActionButtons(); this.renderContents(); diff --git a/cms/static/js/views/modals/edit_outline_item.js b/cms/static/js/views/modals/edit_outline_item.js new file mode 100644 index 000000000000..1a4274dcf9c3 --- /dev/null +++ b/cms/static/js/views/modals/edit_outline_item.js @@ -0,0 +1,232 @@ +/** + * The EditSectionXBlockModal 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/modals/base_modal', + 'date', 'js/views/utils/xblock_utils', 'js/utils/date_utils' +], + function( + $, Backbone, _, gettext, BaseModal, date, XBlockViewUtils, DateUtils + ) { + 'use strict'; + var EditSectionXBlockModal, BaseDateView, ReleaseDateView, DueDateView, + GradingView; + + 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: 'lg', + 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 () { + if (this.model.isChapter() || this.model.isSequential()) { + return _.template( + gettext('<%= sectionName %> Settings'), + {sectionName: this.model.get('display_name')}); + } else { + return ''; + } + }, + + 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() + }); + 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 + }, 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 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', + + getValue: function () { + return DateUtils.getDate(this.$('#start_date'), this.$('#start_time')); + }, + + clearValue: function (event) { + event.preventDefault(); + this.$('#start_time, #start_date').val(''); + }, + + getMetadata: function () { + return { + 'start': this.getValue() + }; + } + }); + + 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')) + }; + } + }); + + return EditSectionXBlockModal; + }); diff --git a/cms/static/js/views/modals/edit_xblock.js b/cms/static/js/views/modals/edit_xblock.js index 31eed3a41564..99698535d30b 100644 --- a/cms/static/js/views/modals/edit_xblock.js +++ b/cms/static/js/views/modals/edit_xblock.js @@ -14,7 +14,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie options: $.extend({}, BaseModal.prototype.options, { modalName: 'edit-xblock', - addSaveButton: true + addSaveButton: true, + viewSpecificClasses: 'modal-editor confirm' }), initialize: function() { diff --git a/cms/static/js/views/overview.js b/cms/static/js/views/overview.js index 115445b420d4..f05ba24079e2 100644 --- a/cms/static/js/views/overview.js +++ b/cms/static/js/views/overview.js @@ -1,5 +1,5 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notification", "js/utils/drag_and_drop", - "js/utils/cancel_on_escape", "js/utils/get_date", "js/utils/module"], + "js/utils/cancel_on_escape", "js/utils/date_utils", "js/utils/module"], function (domReady, $, ui, _, gettext, NotificationView, ContentDragger, CancelOnEscape, DateUtils, ModuleUtils) { @@ -61,7 +61,7 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/fe var saveSetSectionScheduleDate = function (e) { e.preventDefault(); - var datetime = DateUtils( + var datetime = DateUtils.getDate( $('.edit-section-publish-settings .start-date'), $('.edit-section-publish-settings .start-time') ); diff --git a/cms/static/js/views/uploads.js b/cms/static/js/views/uploads.js index a216c603e7a4..4b2cd0d62007 100644 --- a/cms/static/js/views/uploads.js +++ b/cms/static/js/views/uploads.js @@ -9,7 +9,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "jquery options: $.extend({}, BaseModal.prototype.options, { modalName: 'assetupload', modalSize: 'med', - successMessageTimeout: 2000 // 2 seconds + successMessageTimeout: 2000, // 2 seconds + viewSpecificClasses: 'confirm' }), initialize: function() { diff --git a/cms/static/js/views/utils/xblock_utils.js b/cms/static/js/views/utils/xblock_utils.js index 96e42277a791..fe1bb8971a73 100644 --- a/cms/static/js/views/utils/xblock_utils.js +++ b/cms/static/js/views/utils/xblock_utils.js @@ -4,7 +4,7 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils", "js/utils/module"], function($, _, gettext, ViewUtils, ModuleUtils) { var addXBlock, deleteXBlock, createUpdateRequestData, updateXBlockField, VisibilityState, - getXBlockVisibilityClass, getXBlockListTypeClass; + getXBlockVisibilityClass, getXBlockListTypeClass, updateXBlockFields; /** * Represents the possible visibility states for an xblock: @@ -105,9 +105,9 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils", "js/util /** * Updates the specified field of an xblock to a new value. - * @param xblockInfo The XBlockInfo model representing the xblock. - * @param fieldName The xblock field name to be updated. - * @param newValue The new value for the field. + * @param {Backbone Model} xblockInfo The XBlockInfo model representing the xblock. + * @param {String} fieldName The xblock field name to be updated. + * @param {*} newValue The new value for the field. * @returns {jQuery promise} A promise representing the updating of the field. */ updateXBlockField = function(xblockInfo, fieldName, newValue) { @@ -118,6 +118,22 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils", "js/util }); }; + /** + * Updates the specified fields of an xblock to a new values. + * @param {Backbone Model} xblockInfo The XBlockInfo model representing the xblock. + * @param {Object} xblockData Object representing xblock data as accepted on server. + * @param {Object} [options] Hash with options. + * @returns {jQuery promise} A promise representing the updating of the xblock values. + */ + updateXBlockFields = function(xblockInfo, xblockData, options) { + options = _.extend({}, { patch: true }, options); + return ViewUtils.runOperationShowingMessage(gettext('Saving…'), + function() { + return xblockInfo.save(xblockData, options); + } + ); + }; + /** * Returns the CSS class to represent the specified xblock visibility state. */ @@ -155,6 +171,7 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils", "js/util 'deleteXBlock': deleteXBlock, 'updateXBlockField': updateXBlockField, 'getXBlockVisibilityClass': getXBlockVisibilityClass, - 'getXBlockListTypeClass': getXBlockListTypeClass + 'getXBlockListTypeClass': getXBlockListTypeClass, + 'updateXBlockFields': updateXBlockFields }; }); diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss index 8df685f5f0b7..3e1cac644ccd 100644 --- a/cms/static/sass/elements/_modal-window.scss +++ b/cms/static/sass/elements/_modal-window.scss @@ -5,6 +5,7 @@ [class*="view-"] { // basic modal content + // ------------------------ .modal-window { @extend %ui-depth3; @include box-sizing(border-box); @@ -51,6 +52,40 @@ } } + // sections within a modal + .modal-section { + margin-bottom: ($baseline/2); + + &:last-child { + margin-bottom: 0; + } + } + + .modal-section-title { + @extend %t-title6; + margin: 0 0 ($baseline/2) 0; + border-bottom: 1px solid $gray-l4; + padding-bottom: ($baseline/4); + color: $gray-d2; + } + + .modal-section-content { + + .list-fields, .list-actions { + display: inline-block; + vertical-align: middle; + } + + .list-actions { + @extend %actions-list; + margin-left: ($baseline/4); + + .action-button { + @extend %t-icon4; + } + } + } + // TODO: need to sync up (alongside general editing mode) with xblocks.scss UI .modal-chin, .xblock-actions, @@ -86,6 +121,7 @@ // small modals - quick editors and dialogs + // ------------------------ .modal-sm { width: 30%; min-width: ($baseline*15); @@ -96,6 +132,7 @@ } // medium modals - forms and interactives + // ------------------------ .modal-med { width: 40%; min-width: ($baseline*18); @@ -106,11 +143,16 @@ } // large modals - component editors and interactives + // ------------------------ .modal-lg { width: 70%; min-width: ($baseline*27.5); height: auto; + .modal-content { + padding: $baseline; + } + &.modal-editor { .modal-header { @@ -162,6 +204,7 @@ // specific modal overrides + // ------------------------ // upload modal .assetupload-modal { @@ -191,6 +234,80 @@ } } + // edit outline item settings + .edit-outline-item-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: 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; + } + } + } + // xblock custom actions .modal-window .editor-with-buttons { margin-bottom: ($baseline*3); diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index dbf407a2f1f3..e7c7b0c26a9a 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -28,7 +28,8 @@ <%block name="header_extras"> -% for template_name in ['course-outline', 'xblock-string-field-editor']: + +% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'edit-outline-item-modal']: diff --git a/cms/templates/js/basic-modal.underscore b/cms/templates/js/basic-modal.underscore index 4d661a45d5b9..95059dd36c46 100644 --- a/cms/templates/js/basic-modal.underscore +++ b/cms/templates/js/basic-modal.underscore @@ -4,7 +4,7 @@ aria-hidden="" role="dialog"> -