diff --git a/cms/static/coffee/spec/views/overview_spec.coffee b/cms/static/coffee/spec/views/overview_spec.coffee
index d0e6bb77d3..ee7a41c709 100644
--- a/cms/static/coffee/spec/views/overview_spec.coffee
+++ b/cms/static/coffee/spec/views/overview_spec.coffee
@@ -1,7 +1,7 @@
describe "Course Overview", ->
beforeEach ->
- _.each ["/static/js/vendor/date.js", "/static/js/vendor/timepicker/jquery.timepicker.js", "/jsi18n/"], (path) ->
+ _.each ["/static/js/vendor/date.js", "/static/js/vendor/timepicker/jquery.timepicker.js", "/jsi18n/", "/static/js/vendor/draggabilly.pkgd.js"], (path) ->
appendSetFixtures """
"""
@@ -45,15 +45,23 @@ describe "Course Overview", ->
"""
- # appendSetFixtures """
- #
- # """
+ appendSetFixtures """
+
+
+
+
+
+
+
+ """#"
spyOn(window, 'saveSetSectionScheduleDate').andCallThrough()
# Have to do this here, as it normally gets bound in document.ready()
@@ -68,6 +76,13 @@ describe "Course Overview", ->
requests = @requests = []
@xhr.onCreate = (req) -> requests.push(req)
+ CMS.Views.Draggabilly.makeDraggable(
+ '.unit',
+ '.unit-drag-handle',
+ 'ol.sortable-unit-list',
+ 'li.branch, article.subsection-body'
+ )
+
afterEach ->
delete window.analytics
delete window.course_location_analytics
@@ -100,3 +115,125 @@ describe "Course Overview", ->
$('a.delete-section-button').click()
$('a.action-primary').click()
expect(@notificationSpy).toHaveBeenCalled()
+
+ describe "findDestination", ->
+ it "correctly finds the drop target of a drag", ->
+ $ele = $('#unit-1')
+ $ele.offset(
+ top: $ele.offset().top + 10, left: $ele.offset().left
+ )
+ destination = CMS.Views.Draggabilly.findDestination($ele)
+ expect(destination.ele).toBe($('#unit-2'))
+ expect(destination.attachMethod).toBe('before')
+
+ it "can drag and drop across section boundaries", ->
+ $ele = $('#unit-1')
+ $ele.offset(
+ top: $('#unit-4').offset().top + 10
+ left: $ele.offset().left
+ )
+ destination = CMS.Views.Draggabilly.findDestination($ele)
+ expect(destination.ele).toBe($('#unit-4'))
+ expect(destination.attachMethod).toBe('after')
+
+ it "can drag into an empty list", ->
+ $ele = $('#unit-1')
+ $ele.offset(
+ top: $('#list-3').offset().top + 10
+ left: $ele.offset().left
+ )
+ destination = CMS.Views.Draggabilly.findDestination($ele)
+ expect(destination.ele).toBe($('#list-3'))
+ expect(destination.attachMethod).toBe('prepend')
+
+ it "reports a null destination on a failed drag", ->
+ $ele = $('#unit-1')
+ $ele.offset(
+ top: $ele.offset().top + 200, left: $ele.offset().left
+ )
+ destination = CMS.Views.Draggabilly.findDestination($ele)
+ expect(destination).toEqual(
+ ele: null
+ attachMethod: ""
+ )
+
+ describe "onDragStart", ->
+ it "sets the dragState to its default values", ->
+ expect(CMS.Views.Draggabilly.dragState).toEqual({})
+ # Call with some dummy data
+ CMS.Views.Draggabilly.onDragStart(
+ {element: $('#unit-1')},
+ null,
+ null
+ )
+ expect(CMS.Views.Draggabilly.dragState).toEqual(
+ offset: $('#unit-1').offset()
+ dropDestination: null,
+ expandTimer: null,
+ toExpand: null
+ )
+
+ describe "onDragMove", ->
+ it "clears the expand timer state", ->
+ timerSpy = spyOn(window, 'clearTimeout').andCallThrough()
+ $ele = $('#unit-1')
+ $ele.offset(
+ top: $ele.offset().top + 10
+ left: $ele.offset().left
+ )
+ CMS.Views.Draggabilly.onDragMove(
+ {element: $ele},
+ null,
+ null
+ )
+ expect(timerSpy).toHaveBeenCalled()
+ timerSpy.reset()
+
+ it "adds the correct CSS class to the drop destination", ->
+ $ele = $('#unit-1')
+ $ele.offset(
+ top: $ele.offset().top + 10, left: $ele.offset().left
+ )
+ CMS.Views.Draggabilly.onDragMove(
+ {element: $ele},
+ '',
+ ''
+ )
+ expect($('#unit-2')).toHaveClass('drop-target drop-target-before')
+
+ describe "onDragEnd", ->
+ beforeEach ->
+ @reorderSpy = spyOn(CMS.Views.Draggabilly, 'handleReorder')
+
+ afterEach ->
+ @reorderSpy.reset()
+
+ it "calls handleReorder on a successful drag", ->
+ $('#unit-1').offset(
+ top: $('#unit-1').offset().top + 10
+ left: $('#unit-1').offset().left
+ )
+ CMS.Views.Draggabilly.onDragEnd(
+ {element: $('#unit-1')},
+ null,
+ {x: $('#unit-1').offset().left}
+ )
+ expect(@reorderSpy).toHaveBeenCalled()
+
+ it "clears out the drag state", ->
+ CMS.Views.Draggabilly.onDragEnd(
+ {element: $('#unit-1')},
+ null,
+ null
+ )
+ expect(CMS.Views.Draggabilly.dragState).toEqual({})
+
+ it "sets the element to the correct position", ->
+ CMS.Views.Draggabilly.onDragEnd(
+ {element: $('#unit-1')},
+ null,
+ null
+ )
+ # Chrome sets the CSS to 'auto', but Firefox uses '0px'.
+ expect(['0px', 'auto']).toContain($('#unit-1').css('top'))
+ expect(['0px', 'auto']).toContain($('#unit-1').css('left'))
diff --git a/cms/static/js/views/overview.js b/cms/static/js/views/overview.js
index 7bfaa8dbea..3c54da9ea3 100644
--- a/cms/static/js/views/overview.js
+++ b/cms/static/js/views/overview.js
@@ -1,13 +1,12 @@
-$(document).ready(function() {
-
- var droppableClasses = 'drop-target drop-target-prepend drop-target-before drop-target-after';
+CMS.Views.Draggabilly = {
+ droppableClasses: 'drop-target drop-target-prepend drop-target-before drop-target-after',
/*
* Determine information about where to drop the currently dragged
* element. Returns the element to attach to and the method of
* attachment ('before', 'after', or 'prepend').
*/
- var findDestination = function(ele) {
+ findDestination: function(ele) {
var eleY = ele.offset().top;
var containers = $(ele.data('droppable-class'));
@@ -55,7 +54,7 @@ $(document).ready(function() {
// element is actually on top of the sibling,
// rather than next to it. This prevents
// saving when expanding/collapsing a list.
- if(Math.abs(eleY - siblingY) < $sibling.height() - 1) {
+ if(Math.abs(eleY - siblingY) < ele.height() - 1) {
return {
ele: $sibling,
attachMethod: siblingY > eleY ? 'before' : 'after'
@@ -69,15 +68,15 @@ $(document).ready(function() {
return {
ele: null,
attachMethod: ''
- };
- };
+ }
+ },
// Information about the current drag.
- var dragState = {};
+ dragState: {},
- var onDragStart = function(draggie, event, pointer) {
+ onDragStart: function(draggie, event, pointer) {
var ele = $(draggie.element);
- dragState = {
+ this.dragState = {
// Where we started, in case of a failed drag
offset: ele.offset(),
// Which element will be dropped into/onto on success
@@ -87,42 +86,42 @@ $(document).ready(function() {
// The list which will be expanded on hover
toExpand: null
};
- };
+ },
- var onDragMove = function(draggie, event, pointer) {
+ onDragMove: function(draggie, event, pointer) {
var ele = $(draggie.element);
- var destinationInfo = findDestination(ele);
+ var destinationInfo = this.findDestination(ele);
var destinationEle = destinationInfo.ele;
var parentList = destinationInfo.parentList;
// Clear the timer if we're not hovering over any element
if(!parentList) {
- clearTimeout(dragState.expandTimer);
+ clearTimeout(this.dragState.expandTimer);
}
// If we're hovering over a new element, clear the timer and
// set a new one
- else if(!dragState.toExpand || parentList[0] !== dragState.toExpand[0]) {
- clearTimeout(dragState.expandTimer);
- dragState.expandTimer = setTimeout(function() {
+ else if(!this.dragState.toExpand || parentList[0] !== this.dragState.toExpand[0]) {
+ clearTimeout(this.dragState.expandTimer);
+ this.dragState.expandTimer = setTimeout(function() {
parentList.removeClass('collapsed');
parentList.find('.expand-collapse-icon').removeClass('expand').addClass('collapse');
}, 400);
- dragState.toExpand = parentList;
+ this.dragState.toExpand = parentList;
}
// Clear out the old destination
- if(dragState.dropDestination) {
- dragState.dropDestination.removeClass(droppableClasses);
+ if(this.dragState.dropDestination) {
+ this.dragState.dropDestination.removeClass(this.droppableClasses);
}
// Mark the new destination
if(destinationEle) {
destinationEle.addClass('drop-target drop-target-' + destinationInfo.attachMethod);
- dragState.dropDestination = destinationEle;
+ this.dragState.dropDestination = destinationEle;
}
- };
+ },
- var onDragEnd = function(draggie, event, pointer) {
+ onDragEnd: function(draggie, event, pointer) {
var ele = $(draggie.element);
- var destinationInfo = findDestination(ele);
+ var destinationInfo = this.findDestination(ele);
var destination = destinationInfo.ele;
// If the drag succeeded, rearrange the DOM and send the result.
@@ -134,7 +133,7 @@ $(document).ready(function() {
}
var method = destinationInfo.attachMethod;
destination[method](ele);
- handleReorder(ele);
+ this.handleReorder(ele);
}
// Everything in its right place
@@ -144,17 +143,17 @@ $(document).ready(function() {
});
// Clear dragging state in preparation for the next event.
- if(dragState.dropDestination) {
- dragState.dropDestination.removeClass(droppableClasses);
+ if(this.dragState.dropDestination) {
+ this.dragState.dropDestination.removeClass(this.droppableClasses);
}
- clearTimeout(dragState.expandTimer);
- dragState = {};
- };
+ clearTimeout(this.dragState.expandTimer);
+ this.dragState = {};
+ },
/*
* Find all parent-child changes and save them.
*/
- var handleReorder = function(ele) {
+ handleReorder: function(ele) {
var parentSelector = ele.data('parent-location-selector');
var childrenSelector = ele.data('child-selector');
var newParentEle = ele.parents(parentSelector).first();
@@ -166,7 +165,7 @@ $(document).ready(function() {
var oldParentEle = $(parentSelector).filter(function() {
return $(this).data('id') === oldParentID;
});
- saveItem(oldParentEle, childrenSelector, function() {
+ this.saveItem(oldParentEle, childrenSelector, function() {
ele.data('parent-id', newParentID);
});
}
@@ -174,17 +173,17 @@ $(document).ready(function() {
title: gettext('Saving…')
});
saving.show();
- saveItem(newParentEle, childrenSelector, function() {
+ this.saveItem(newParentEle, childrenSelector, function() {
saving.hide();
});
- };
+ },
/*
* Actually save the update to the server. Takes the element
* representing the parent item to save, a CSS selector to find
* its children, and a success callback.
*/
- var saveItem = function(ele, childrenSelector, success) {
+ saveItem: function(ele, childrenSelector, success) {
// Find all current child IDs.
var children = _.map(
ele.find(childrenSelector),
@@ -203,14 +202,14 @@ $(document).ready(function() {
}),
success: success
});
- };
+ },
/*
* Make `type` draggable using `handleClass`, able to be dropped
* into `droppableClass`, and with parent type
* `parentLocationSelector`.
*/
- var makeDraggable = function(type, handleClass, droppableClass, parentLocationSelector) {
+ makeDraggable: function(type, handleClass, droppableClass, parentLocationSelector) {
_.each(
$(type),
function(ele) {
@@ -222,29 +221,31 @@ $(document).ready(function() {
handle: handleClass,
axis: 'y'
});
- draggable.on('dragStart', onDragStart);
- draggable.on('dragMove', onDragMove);
- draggable.on('dragEnd', onDragEnd);
+ draggable.on('dragStart', _.bind(CMS.Views.Draggabilly.onDragStart, CMS.Views.Draggabilly));
+ draggable.on('dragMove', _.bind(CMS.Views.Draggabilly.onDragMove, CMS.Views.Draggabilly));
+ draggable.on('dragEnd', _.bind(CMS.Views.Draggabilly.onDragEnd, CMS.Views.Draggabilly));
}
);
- };
+ }
+};
+$(document).ready(function() {
// Section
- makeDraggable(
+ CMS.Views.Draggabilly.makeDraggable(
'.courseware-section',
'.section-drag-handle',
'.courseware-overview',
'article.courseware-overview'
);
// Subsection
- makeDraggable(
+ CMS.Views.Draggabilly.makeDraggable(
'.id-holder',
'.subsection-drag-handle',
'.subsection-list > ol',
'.courseware-section'
);
// Unit
- makeDraggable(
+ CMS.Views.Draggabilly.makeDraggable(
'.unit',
'.unit-drag-handle',
'ol.sortable-unit-list',