diff --git a/cms/djangoapps/contentstore/features/course-outline.py b/cms/djangoapps/contentstore/features/course-outline.py index 8786cfcaf1..f9ceb6ff68 100644 --- a/cms/djangoapps/contentstore/features/course-outline.py +++ b/cms/djangoapps/contentstore/features/course-outline.py @@ -131,14 +131,3 @@ def all_sections_are_collapsed_or_expanded(step, text): def change_grading_status(step): world.css_find('a.menu-toggle').click() world.css_find('.menu li').first.click() - - -@step(u'I reorder subsections') -def reorder_subsections(_step): - draggable_css = '.subsection-drag-handle' - ele = world.css_find(draggable_css).first - ele.action_chains.drag_and_drop_by_offset( - ele._element, - 0, - 25 - ).perform() diff --git a/cms/static/js/spec/utils/drag_and_drop_spec.js b/cms/static/js/spec/utils/drag_and_drop_spec.js index 676e6b992a..9ec7dd5924 100644 --- a/cms/static/js/spec/utils/drag_and_drop_spec.js +++ b/cms/static/js/spec/utils/drag_and_drop_spec.js @@ -1,10 +1,32 @@ -define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_helpers/create_sinon", "jquery"], - function (ContentDragger, Notification, create_sinon, $) { +define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_helpers/create_sinon", "jquery", "underscore"], + function (ContentDragger, Notification, create_sinon, $, _) { describe("Overview drag and drop functionality", function () { beforeEach(function () { setFixtures(readFixtures('mock/mock-outline.underscore')); - ContentDragger.makeDraggable('.unit', '.unit-drag-handle', 'ol.sortable-unit-list', 'li.courseware-subsection, article.subsection-body'); - ContentDragger.makeDraggable('.courseware-subsection', '.subsection-drag-handle', '.sortable-subsection-list', 'section'); + _.each( + $('.unit'), + function (element) { + ContentDragger.makeDraggable(element, { + type: '.unit', + handleClass: '.unit-drag-handle', + droppableClass: 'ol.sortable-unit-list', + parentLocationSelector: 'li.courseware-subsection', + refresh: jasmine.createSpy('Spy on Unit') + }); + } + ); + _.each( + $('.courseware-subsection'), + function (element) { + ContentDragger.makeDraggable(element, { + type: '.courseware-subsection', + handleClass: '.subsection-drag-handle', + droppableClass: '.sortable-subsection-list', + parentLocationSelector: 'section', + refresh: jasmine.createSpy('Spy on Subsection') + }); + } + ); }); describe("findDestination", function () { @@ -115,7 +137,7 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel }); it("can drag into a collapsed list", function () { var $ele, destination; - $('#subsection-2').addClass('collapsed'); + $('#subsection-2').addClass('is-collapsed'); $ele = $('#unit-2'); $ele.offset({ top: $('#subsection-2').offset().top + 3, @@ -142,11 +164,11 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel }); }); it("collapses expanded elements", function () { - expect($('#subsection-1')).not.toHaveClass('collapsed'); + expect($('#subsection-1')).not.toHaveClass('is-collapsed'); ContentDragger.onDragStart({ element: $('#subsection-1') }, null, null); - expect($('#subsection-1')).toHaveClass('collapsed'); + expect($('#subsection-1')).toHaveClass('is-collapsed'); expect($('#subsection-1')).toHaveClass('expand-on-drop'); }); }); @@ -246,16 +268,16 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel expect(['0px', 'auto']).toContain($('#unit-1').css('left')); }); it("expands an element if it was collapsed on drag start", function () { - $('#subsection-1').addClass('collapsed'); + $('#subsection-1').addClass('is-collapsed'); $('#subsection-1').addClass('expand-on-drop'); ContentDragger.onDragEnd({ element: $('#subsection-1') }, null, null); - expect($('#subsection-1')).not.toHaveClass('collapsed'); + expect($('#subsection-1')).not.toHaveClass('is-collapsed'); expect($('#subsection-1')).not.toHaveClass('expand-on-drop'); }); it("expands a collapsed element when something is dropped in it", function () { - $('#subsection-2').addClass('collapsed'); + $('#subsection-2').addClass('is-collapsed'); ContentDragger.dragState.dropDestination = $('#list-2'); ContentDragger.dragState.attachMethod = "prepend"; ContentDragger.dragState.parentList = $('#subsection-2'); @@ -264,7 +286,7 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel }, null, { clientX: $('#unit-1').offset().left }); - expect($('#subsection-2')).not.toHaveClass('collapsed'); + expect($('#subsection-2')).not.toHaveClass('is-collapsed'); }); }); describe("AJAX", function () { @@ -306,6 +328,10 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel expect(this.savingSpies.hide).toHaveBeenCalled(); this.clock.tick(1001); expect($('#unit-1')).not.toHaveClass('was-dropped'); + // source + expect($('#subsection-1').data('refresh')).toHaveBeenCalled(); + // target + expect($('#subsection-2').data('refresh')).toHaveBeenCalled(); }); }); }); 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 30a7671176..0e75353199 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -179,7 +179,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" create_sinon.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-course'); create_sinon.respondWithJson(requests, mockSingleSectionCourseJSON); expect(outlinePage.$('.no-content')).not.toExist(); - expect(outlinePage.$('.list-sections li').data('locator')).toEqual('mock-section'); + expect(outlinePage.$('.list-sections li.outline-section').data('locator')).toEqual('mock-section'); }); it('can add a second section', function() { @@ -237,7 +237,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" create_sinon.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-course'); create_sinon.respondWithJson(requests, mockSingleSectionCourseJSON); expect(outlinePage.$('.no-content')).not.toExist(); - expect(outlinePage.$('.list-sections li').data('locator')).toEqual('mock-section'); + expect(outlinePage.$('.list-sections li.outline-section').data('locator')).toEqual('mock-section'); }); it('remains empty if an add fails', function() { @@ -303,7 +303,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" requestCount = requests.length; create_sinon.respondWithError(requests); expect(requests.length).toBe(requestCount); // No additional requests should be made - expect(outlinePage.$('.list-sections li').data('locator')).toEqual('mock-section'); + expect(outlinePage.$('.list-sections li.outline-section').data('locator')).toEqual('mock-section'); }); it('can add a subsection', function() { diff --git a/cms/static/js/utils/drag_and_drop.js b/cms/static/js/utils/drag_and_drop.js index 2479ec47ba..c68329a534 100644 --- a/cms/static/js/utils/drag_and_drop.js +++ b/cms/static/js/utils/drag_and_drop.js @@ -6,6 +6,7 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif droppableClasses: 'drop-target drop-target-prepend drop-target-before drop-target-after', validDropClass: "valid-drop", expandOnDropClass: "expand-on-drop", + collapsedClass: "is-collapsed", /* * Determine information about where to drop the currently dragged @@ -14,7 +15,7 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif */ findDestination: function (ele, yChange) { var eleY = ele.offset().top; - var eleYEnd = eleY + ele.height(); + var eleYEnd = eleY + ele.outerHeight(); var containers = $(ele.data('droppable-class')); for (var i = 0; i < containers.length; i++) { @@ -28,7 +29,7 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif // element is on top of its parent list -- don't check the // position of the container var parentList = container.parents(ele.data('parent-location-selector')).first(); - if (parentList.hasClass('collapsed')) { + if (parentList.hasClass(this.collapsedClass)) { var parentListTop = parentList.offset().top; // To make it easier to drop subsections into collapsed sections (which have // a lot of visual padding around them), allow a fudge factor around the @@ -36,7 +37,7 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif var collapseFudge = 10; if (Math.abs(eleY - parentListTop) < collapseFudge || (eleY > parentListTop && - eleYEnd - collapseFudge <= parentListTop + parentList.height()) + eleYEnd - collapseFudge <= parentListTop + parentList.outerHeight()) ) { return { ele: container, @@ -65,7 +66,7 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif for (var j = 0; j < siblings.length; j++) { var $sibling = $(siblings[j]); var siblingY = $sibling.offset().top; - var siblingHeight = $sibling.height(); + var siblingHeight = $sibling.outerHeight(); var siblingYEnd = siblingY + siblingHeight; // Facilitate dropping into the beginning or end of a list @@ -158,12 +159,16 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif // The direction the drag is moving in (negative means up, positive down). dragDirection: 0 }; - if (!ele.hasClass('collapsed')) { - ele.addClass('collapsed'); + if (!ele.hasClass(this.collapsedClass)) { + ele.addClass(this.collapsedClass); ele.find('.expand-collapse').first().addClass('expand').removeClass('collapse'); // onDragStart gets called again after the collapse, so we can't just store a variable in the dragState. ele.addClass(this.expandOnDropClass); } + + // We should remove this class name before start dragging to + // avoid performance issues. + ele.removeClass('was-dragging'); }, onDragMove: function (draggie, event, pointer) { @@ -251,41 +256,46 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif }, pointerInBounds: function (pointer, ele) { - return pointer.clientX >= ele.offset().left && pointer.clientX < ele.offset().left + ele.width(); + return pointer.clientX >= ele.offset().left && pointer.clientX < ele.offset().left + ele.outerWidth(); }, expandElement: function (ele) { - ele.removeClass('collapsed'); + ele.removeClass(this.collapsedClass); ele.find('.expand-collapse').first().removeClass('expand').addClass('collapse'); }, /* * Find all parent-child changes and save them. */ - handleReorder: function (ele) { - var parentSelector = ele.data('parent-location-selector'); - var childrenSelector = ele.data('child-selector'); - var newParentEle = ele.parents(parentSelector).first(); - var newParentLocator = newParentEle.data('locator'); - var oldParentLocator = ele.data('parent'); + handleReorder: function (element) { + var parentSelector = element.data('parent-location-selector'), + childrenSelector = element.data('child-selector'), + newParentEle = element.parents(parentSelector).first(), + newParentLocator = newParentEle.data('locator'), + oldParentLocator = element.data('parent'), + oldParentEle, saving; // If the parent has changed, update the children of the old parent. if (newParentLocator !== oldParentLocator) { // Find the old parent element. - var oldParentEle = $(parentSelector).filter(function () { + oldParentEle = $(parentSelector).filter(function () { return $(this).data('locator') === oldParentLocator; }); this.saveItem(oldParentEle, childrenSelector, function () { - ele.data('parent', newParentLocator); + element.data('parent', newParentLocator); + _.each([oldParentEle, newParentEle], function (element) { + var refresh = element.data('refresh'); + if (_.isFunction(refresh)) { refresh(); } + }); }); } - var saving = new NotificationView.Mini({ + saving = new NotificationView.Mini({ title: gettext('Saving…') }); saving.show(); - ele.addClass('was-dropped'); + element.addClass('was-dropped'); // Timeout interval has to match what is in the CSS. setTimeout(function () { - ele.removeClass('was-dropped'); + element.removeClass('was-dropped'); }, 1000); this.saveItem(newParentEle, childrenSelector, function () { saving.hide(); @@ -318,27 +328,43 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif }, /* - * Make `type` draggable using `handleClass`, able to be dropped - * into `droppableClass`, and with parent type - * `parentLocationSelector`. + * Make DOM element with class `type` draggable using `handleClass`, able to be dropped + * into `droppableClass`, and with parent type `parentLocationSelector`. + * @param {DOM element, jQuery element} element + * @param {Object} options The list of options. Possible options: + * `type` - class name of the element. + * `handleClass` - specifies on what element the drag interaction starts. + * `droppableClass` - specifies on what elements draggable element can be dropped. + * `parentLocationSelector` - class name of a parent element with data-locator. + * `refresh` - method that will be called after dragging to refresh + * views of the target and source xblocks. */ - makeDraggable: function (type, handleClass, droppableClass, parentLocationSelector) { - _.each( - $(type), - function (ele) { - // Remember data necessary to reconstruct the parent-child relationships - $(ele).data('droppable-class', droppableClass); - $(ele).data('parent-location-selector', parentLocationSelector); - $(ele).data('child-selector', type); - var draggable = new Draggabilly(ele, { - handle: handleClass, - containment: '.wrapper-dnd' - }); - draggable.on('dragStart', _.bind(contentDragger.onDragStart, contentDragger)); - draggable.on('dragMove', _.bind(contentDragger.onDragMove, contentDragger)); - draggable.on('dragEnd', _.bind(contentDragger.onDragEnd, contentDragger)); - } - ); + makeDraggable: function (element, options) { + var draggable; + options = _.defaults({ + type: null, + handleClass: null, + droppableClass: null, + parentLocationSelector: null, + refresh: null + }, options); + + if ($(element).data('droppable-class') !== options.droppableClass) { + $(element).data({ + 'droppable-class': options.droppableClass, + 'parent-location-selector': options.parentLocationSelector, + 'child-selector': options.type, + 'refresh': options.refresh + }); + + draggable = new Draggabilly(element, { + handle: options.handleClass, + containment: '.wrapper-dnd' + }); + draggable.on('dragStart', _.bind(contentDragger.onDragStart, contentDragger)); + draggable.on('dragMove', _.bind(contentDragger.onDragMove, contentDragger)); + draggable.on('dragEnd', _.bind(contentDragger.onDragEnd, contentDragger)); + } } }; diff --git a/cms/static/js/views/course_outline.js b/cms/static/js/views/course_outline.js index adfc1ca365..22b222b50b 100644 --- a/cms/static/js/views/course_outline.js +++ b/cms/static/js/views/course_outline.js @@ -9,15 +9,20 @@ * - 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"], - function($, _, XBlockOutlineView, ViewUtils, XBlockOutlineInfo, EditSectionXBlockModal) { + "js/models/xblock_outline_info", "js/views/modals/edit_outline_item", "js/utils/drag_and_drop"], + function($, _, XBlockOutlineView, ViewUtils, XBlockOutlineInfo, EditSectionXBlockModal, ContentDragger) { var CourseOutlineView = XBlockOutlineView.extend({ // takes XBlockOutlineInfo as a model templateName: 'course-outline', + render: function() { + var renderResult = XBlockOutlineView.prototype.render.call(this); + this.makeContentDraggable(this.el); + return renderResult; + }, + shouldExpandChildren: function() { // Expand the children if this xblock's locator is in the initially expanded state if (this.initialState && _.contains(this.initialState.expanded_locators, this.model.id)) { @@ -154,6 +159,36 @@ define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_ event.preventDefault(); this.editXBlock(); }.bind(this)); + }, + + makeContentDraggable: function(element) { + if ($(element).hasClass("outline-section")) { + ContentDragger.makeDraggable(element, { + type: '.outline-section', + handleClass: '.section-drag-handle', + droppableClass: 'ol.list-sections', + parentLocationSelector: 'article.outline', + refresh: this.refresh.bind(this) + }); + } + else if ($(element).hasClass("outline-subsection")) { + ContentDragger.makeDraggable(element, { + type: '.outline-subsection', + handleClass: '.subsection-drag-handle', + droppableClass: 'ol.list-subsections', + parentLocationSelector: 'li.outline-section', + refresh: this.refresh.bind(this) + }); + } + else if ($(element).hasClass("outline-unit")) { + ContentDragger.makeDraggable(element, { + type: '.outline-unit', + handleClass: '.unit-drag-handle', + droppableClass: 'ol.list-units', + parentLocationSelector: 'li.outline-subsection', + refresh: this.refresh.bind(this) + }); + } } }); diff --git a/cms/static/js/views/overview.js b/cms/static/js/views/overview.js index f05ba24079..f33f0c908f 100644 --- a/cms/static/js/views/overview.js +++ b/cms/static/js/views/overview.js @@ -1,6 +1,6 @@ -define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notification", "js/utils/drag_and_drop", +define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notification", "js/utils/cancel_on_escape", "js/utils/date_utils", "js/utils/module"], - function (domReady, $, ui, _, gettext, NotificationView, ContentDragger, CancelOnEscape, + function (domReady, $, ui, _, gettext, NotificationView, CancelOnEscape, DateUtils, ModuleUtils) { var modalSelector = '.edit-section-publish-settings'; @@ -37,9 +37,9 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/fe var closeModalNew = function (e) { - if (e) { + if (e) { e.preventDefault(); - }; + } $('body').removeClass('modal-window-is-shown'); $('.edit-section-publish-settings').removeClass('is-shown'); }; @@ -230,27 +230,6 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/fe $('.new-courseware-section-button').bind('click', addNewSection); $('.new-subsection-item').bind('click', addNewSubsection); - // Section - ContentDragger.makeDraggable( - '.courseware-section', - '.section-drag-handle', - '.courseware-overview', - 'article.courseware-overview' - ); - // Subsection - ContentDragger.makeDraggable( - '.id-holder', - '.subsection-drag-handle', - '.subsection-list > ol', - '.courseware-section' - ); - // Unit - ContentDragger.makeDraggable( - '.unit', - '.unit-drag-handle', - 'ol.sortable-unit-list', - 'li.courseware-subsection, article.subsection-body' - ); }); return { diff --git a/cms/static/sass/elements/_controls.scss b/cms/static/sass/elements/_controls.scss index f0f7179cb7..b5447c68cb 100644 --- a/cms/static/sass/elements/_controls.scss +++ b/cms/static/sass/elements/_controls.scss @@ -394,6 +394,9 @@ box-shadow: 0 1px 2px 0 $blue-t2; } } + .was-dragging { + @include transition(transform $tmg-f2 ease-in-out 0); + } // UI: drag state - was dragging .was-dragging { diff --git a/cms/static/sass/views/_outline.scss b/cms/static/sass/views/_outline.scss index 12a2112381..a0992eefa5 100644 --- a/cms/static/sass/views/_outline.scss +++ b/cms/static/sass/views/_outline.scss @@ -224,6 +224,10 @@ color: $blue; } } + + &.is-dragging { + @include transition-property(none); + } } // item: title diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore index 3003702341..50294bb55c 100644 --- a/cms/templates/js/course-outline.underscore +++ b/cms/templates/js/course-outline.underscore @@ -35,6 +35,8 @@ if (statusType === 'warning') {