From fb150bcf64d0553b7f263102746139584febbea9 Mon Sep 17 00:00:00 2001 From: cahrens Date: Sat, 5 Apr 2014 17:01:51 -0400 Subject: [PATCH] Drag and drop on container page. STUD-1309 --- cms/static/coffee/spec/main.coffee | 6 + cms/static/js/spec/views/container_spec.js | 198 +++++++++++ cms/static/js/views/container.js | 117 +++++++ cms/static/js_test.yml | 1 + .../js/mock/mock-container.underscore | 209 ++++++++++++ common/static/js/vendor/jquery.simulate.js | 312 ++++++++++++++++++ lms/templates/vert_module.html | 18 +- 7 files changed, 856 insertions(+), 5 deletions(-) create mode 100644 cms/static/js/spec/views/container_spec.js create mode 100644 cms/static/js/views/container.js create mode 100644 cms/templates/js/mock/mock-container.underscore create mode 100644 common/static/js/vendor/jquery.simulate.js diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index d8126c77e8..0dd3e24ed9 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -18,6 +18,7 @@ requirejs.config({ "jquery.iframe-transport": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport", "jquery.inputnumber": "xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill", "jquery.immediateDescendents": "xmodule_js/common_static/coffee/src/jquery.immediateDescendents", + "jquery.simulate": "xmodule_js/common_static/js/vendor/jquery.simulate", "datepair": "xmodule_js/common_static/js/vendor/timepicker/datepair", "date": "xmodule_js/common_static/js/vendor/date", "underscore": "xmodule_js/common_static/js/vendor/underscore-min", @@ -100,6 +101,10 @@ requirejs.config({ deps: ["jquery"], exports: "jQuery.fn.inputNumber" }, + "jquery.simulate": { + deps: ["jquery"], + exports: "jQuery.fn.simulate" + }, "jquery.tinymce": { deps: ["jquery", "tinymce"], exports: "jQuery.fn.tinymce" @@ -216,6 +221,7 @@ define([ "js/spec/views/baseview_spec", "js/spec/views/paging_spec", + "js/spec/views/container_spec", "js/spec/views/unit_spec", "js/spec/views/xblock_spec", "js/spec/views/xblock_editor_spec", diff --git a/cms/static/js/spec/views/container_spec.js b/cms/static/js/spec/views/container_spec.js new file mode 100644 index 0000000000..53de72a3be --- /dev/null +++ b/cms/static/js/spec/views/container_spec.js @@ -0,0 +1,198 @@ +define([ "jquery", "js/spec/create_sinon", "URI", "js/views/container", "js/models/xblock_info", + "js/views/feedback_notification", "jquery.simulate", + "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], + function ($, create_sinon, URI, ContainerView, XBlockInfo, Notification) { + + describe("Container View", function () { + + describe("Supports reordering components", function () { + + var model, containerView, mockContainerHTML, respondWithMockXBlockFragment; + + // TODO: this will all need to be updated according to Andy's mock HTML, + // and locators should be draft. + var splitTestUrl = "/xblock/ccc.dd.ee/branch/draft/block/AB_Test"; + + var groupAUrl = "/xblock/ccc.dd.ee/branch/published/block/group_a"; + var groupA = "ccc.dd.ee/branch/published/block/group_a"; + var groupAText = "ccc.dd.ee/branch/published/block/html_4658c0f4c400"; + var groupAVideo = "ccc.dd.ee/branch/published/block/group_a_video"; + + var groupBUrl = "/xblock/ccc.dd.ee/branch/published/block/group_b"; + var groupB = "ccc.dd.ee/branch/published/block/group_b"; + var groupBText = "ccc.dd.ee/branch/published/block/html_b5c18016d991"; + var groupBProblem = "ccc.dd.ee/branch/published/block/Checkboxes"; + + // TODO: switch to using Andy's mock container view files (which uses mock xblocks). + mockContainerHTML = readFixtures('mock/mock-container.underscore'); + + respondWithMockXBlockFragment = function (requests, response) { + var requestIndex = requests.length - 1; + create_sinon.respondWithJson(requests, response, requestIndex); + }; + + beforeEach(function () { + model = new XBlockInfo({ + id: 'testCourse/branch/draft/split_test/splitFFF', + display_name: 'Test AB Test', + category: 'split_test' + }); + + containerView = new ContainerView({ + model: model, + view: 'container_preview' + }); + }); + + afterEach(function () { + containerView.remove(); + }); + + var init = function (caller) { + var requests = create_sinon.requests(caller); + containerView.render(); + + respondWithMockXBlockFragment(requests, { + html: mockContainerHTML, + "resources": [] + }); + + $('body').append(containerView.$el); + return requests; + }; + + var dragHandle = function (index, dy) { + containerView.$el.find(".drag-handle:eq(" + index + ")").simulate("drag", {dy: dy}); + }; + + var verifyRequest = function (requests, reorderCallIndex, expectedURL, expectedChildren) { + // 0th call is the response to the initial render call to get HTML. + var request = requests[reorderCallIndex + 1]; + expect(request.url).toEqual(expectedURL); + var children = (JSON.parse(request.requestBody)).children; + expect(children.length).toEqual(expectedChildren.length); + for (var i = 0; i < children.length; i++) { + expect(children[i]).toEqual(expectedChildren[i]); + } + }; + + var verifyNumReorderCalls = function (requests, expectedCalls) { + // Number of calls will be 1 more than expected because of the initial render call to get HTML. + expect(requests.length).toEqual(expectedCalls + 1); + }; + + var respondToRequest = function (requests, reorderCallIndex, status) { + // Number of calls will be 1 more than expected because of the initial render call to get HTML. + requests[reorderCallIndex + 1].respond(status); + }; + + it('does nothing if item not moved far enough', function () { + var requests = init(this); + // Drag the first thing in Group A (text component) down very slightly, but not past second thing. + dragHandle(1, 5); + verifyNumReorderCalls(requests, 0); + }); + + it('can reorder within a group', function () { + var requests = init(this); + // Drag the first thing in Group A (text component) after the second thing (video). + dragHandle(1, 80); + respondToRequest(requests, 0, 200); + verifyNumReorderCalls(requests, 1); + verifyRequest(requests, 0, groupAUrl, [groupAVideo, groupAText]); + }); + + it('can drag from one group to another', function () { + var requests = init(this); + // Drag the first thing in Group A (text component) into the second group. + dragHandle(1, 200); + respondToRequest(requests, 0, 200); + respondToRequest(requests, 1, 200); + // Will get an event to move into Group B and an event to remove from Group A. + verifyNumReorderCalls(requests, 2); + verifyRequest(requests, 0, groupBUrl, [groupBText, groupAText, groupBProblem]); + verifyRequest(requests, 1, groupAUrl, [groupAVideo]); + }); + + it('does not remove from old group if addition to new group fails', function () { + var requests = init(this); + // Drag the first thing in Group A (text component) into the second group. + dragHandle(1, 200); + respondToRequest(requests, 0, 500); + // Send failure for addition to new group-- no removal event should be received. + verifyNumReorderCalls(requests, 1); + verifyRequest(requests, 0, groupBUrl, [groupBText, groupAText, groupBProblem]); + }); + + it('can swap group A and group B', function () { + var requests = init(this); + // Drag Group B before group A. + dragHandle(3, -200); + respondToRequest(requests, 0, 200); + verifyNumReorderCalls(requests, 1); + verifyRequest(requests, 0, splitTestUrl, [groupB, groupA]); + }); + + + it('can drag a component to the top level, and nest one group in another', function () { + var requests = init(this); + // Drag text item in Group A to the top level (in first position). + dragHandle(1, -20); + respondToRequest(requests, 0, 200); + respondToRequest(requests, 1, 200); + verifyNumReorderCalls(requests, 2); + verifyRequest(requests, 0, splitTestUrl, [groupAText, groupA, groupB]); + verifyRequest(requests, 1, groupAUrl, [groupAVideo]); + + // Drag Group A (only contains video now) into Group B. + dragHandle(1, 150); + respondToRequest(requests, 2, 200); + respondToRequest(requests, 3, 200); + verifyNumReorderCalls(requests, 4); + verifyRequest(requests, 2, groupBUrl, [groupBText, groupA, groupBProblem]); + verifyRequest(requests, 3, splitTestUrl, [groupAText, groupB]); + }); + + describe("Shows a saving message", function () { + var savingSpies; + + beforeEach(function () { + savingSpies = spyOnConstructor(Notification, "Mini", + ["show", "hide"]); + savingSpies.show.andReturn(savingSpies); + }); + + it('hides saving message upon success', function () { + var requests = init(this); + // Drag text item in Group A to the top level (in first position). + dragHandle(1, -20); + expect(savingSpies.constructor).toHaveBeenCalled(); + expect(savingSpies.show).toHaveBeenCalled(); + expect(savingSpies.hide).not.toHaveBeenCalled(); + var savingOptions = savingSpies.constructor.mostRecentCall.args[0]; + expect(savingOptions.title).toMatch(/Saving/); + + respondToRequest(requests, 0, 200); + expect(savingSpies.hide).not.toHaveBeenCalled(); + respondToRequest(requests, 1, 200); + expect(savingSpies.hide).toHaveBeenCalled(); + verifyNumReorderCalls(requests, 2); + }); + + it('does not hide saving message if failure', function () { + var requests = init(this); + // Drag text item in Group A to the top level (in first position). + dragHandle(1, -20); + expect(savingSpies.constructor).toHaveBeenCalled(); + expect(savingSpies.show).toHaveBeenCalled(); + expect(savingSpies.hide).not.toHaveBeenCalled(); + + respondToRequest(requests, 0, 500); + expect(savingSpies.hide).not.toHaveBeenCalled(); + // Since the first reorder call failed, the removal will not be called. + verifyNumReorderCalls(requests, 1); + }); + }); + }); + }); + }); \ No newline at end of file diff --git a/cms/static/js/views/container.js b/cms/static/js/views/container.js new file mode 100644 index 0000000000..4dd192d9d8 --- /dev/null +++ b/cms/static/js/views/container.js @@ -0,0 +1,117 @@ +define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification"], + function ($, _, XBlockView, ModuleUtils, gettext, NotificationView) { + var ContainerView = XBlockView.extend({ + + xblockReady: function () { + XBlockView.prototype.xblockReady.call(this); + var verticalContainer = $(this.$el).find('.vertical-container'), + alreadySortable = $(this.$el).find('.ui-sortable'), + newParent, + oldParent, + self = this; + + alreadySortable.sortable("destroy"); + + verticalContainer.sortable({ + handle: '.drag-handle', + + stop: function (event, ui) { + console.log('stop'); + + if (oldParent === undefined) { + // If no actual change occurred, + // oldParent will never have been set. + return; + } + + var saving = new NotificationView.Mini({ + title: gettext('Saving…') + }); + saving.show(); + + var hideSaving = function () { + saving.hide(); + }; + + // If moving from one container to another, + // add to new container before deleting from old to + // avoid creating an orphan if the addition fails. + if (newParent) { + var removeFromParent = oldParent; + self.reorder(newParent, function () { + self.reorder(removeFromParent, hideSaving); + }); + } + else { + // No new parent, only reordering within same container. + self.reorder(oldParent, hideSaving); + } + + oldParent = undefined; + newParent = undefined; + }, + update: function (event, ui) { + // When dragging from one ol to another, this method + // will be called twice (once for each list). ui.sender will + // be null if the change is related to the list the element + // was originally in (the case of a move within the same container + // or the deletion from a container when moving to a new container). + var parent = $(event.target).closest('.wrapper-xblock'); + if (ui.sender) { + // Move to a new container (the addition part). + newParent = parent; + } + else { + // Reorder inside a container, or deletion when moving to new container. + oldParent = parent; + } + }, + helper: "clone", + opacity: '0.5', + placeholder: 'component-placeholder', + forcePlaceholderSize: true, + axis: 'y', + items: '> .vertical-element', + connectWith: ".vertical-container" + + }); + }, + + reorder: function (targetParent, successCallback) { + console.log('calling reorder for ' + targetParent.data('locator')); + + // Find descendants with class "wrapper-xblock" whose parent == targetParent. + // This is necessary to filter our grandchildren, great-grandchildren, etc. + var children = targetParent.find('.wrapper-xblock').filter(function () { + var parent = $(this).parent().closest('.wrapper-xblock'); + return parent.data('locator') === targetParent.data('locator'); + }); + + var childLocators = _.map( + children, + function (child) { + return $(child).data('locator'); + } + ); + $.ajax({ + url: ModuleUtils.getUpdateUrl(targetParent.data('locator')), + type: 'PUT', + dataType: 'json', + contentType: 'application/json', + data: JSON.stringify({ + children: childLocators + }), + success: function () { + // change data-parent on the element moved. + console.log('SAVED! ' + targetParent.data('locator') + " with " + childLocators.length + " children"); + if (successCallback) { + successCallback(); + } + } + }); + + } + }); + + return ContainerView; + }); // end define(); \ No newline at end of file diff --git a/cms/static/js_test.yml b/cms/static/js_test.yml index ce247967a9..c5d3bff04f 100644 --- a/cms/static/js_test.yml +++ b/cms/static/js_test.yml @@ -34,6 +34,7 @@ lib_paths: - xmodule_js/common_static/js/vendor/jquery.min.js - xmodule_js/common_static/js/vendor/jquery-ui.min.js - xmodule_js/common_static/js/vendor/jquery.cookie.js + - xmodule_js/common_static/js/vendor/jquery.simulate.js - xmodule_js/common_static/js/vendor/underscore-min.js - xmodule_js/common_static/js/vendor/underscore.string.min.js - xmodule_js/common_static/js/vendor/backbone-min.js diff --git a/cms/templates/js/mock/mock-container.underscore b/cms/templates/js/mock/mock-container.underscore new file mode 100644 index 0000000000..e69a12e358 --- /dev/null +++ b/cms/templates/js/mock/mock-container.underscore @@ -0,0 +1,209 @@ +
+
+
A/B Test
+
+
+
+ +
+
    +
  1. +
    + + +
    +
    +
    + Group A +
    +
    +
      +
    • No Actions
    • +
    +
    +
    +
    +
    + +
    +
      +
    1. +
      + + +
      +
      +
      Text
      +
      +
        +
      +
      +
      +
      +
      + Welcome to group A. +
      +
      +
      +
      +
    2. +
    3. +
      + + +
      +
      +
      + Video +
      +
      +
        +
      +
      +
      +
      +
      +

      Video

      +
      +
      +
      +
      +
    4. +
    +
    +
    +
    +
    +
    +
  2. +
  3. +
    + + +
    +
    +
    + Group B +
    +
    +
      +
    • No Actions
    • +
    +
    +
    +
    +
    + +
    +
      +
    1. +
      + + +
      +
      +
      + Text +
      +
      +
        +
      +
      +
      +
      +
      + Welcome to group B. +
      + +
      +
      +
      +
    2. +
    3. +
      + + +
      +
      +
      + Checkboxes +
      +
      +
        +
      +
      +
      +
      +
      +
      +

      + Checkboxes +

      +
      +
      +
      +
      +
      +
    4. +
    +
    +
    +
    +
    +
    +
  4. +
+
+
+
+
+ + diff --git a/common/static/js/vendor/jquery.simulate.js b/common/static/js/vendor/jquery.simulate.js new file mode 100644 index 0000000000..18da5dd16f --- /dev/null +++ b/common/static/js/vendor/jquery.simulate.js @@ -0,0 +1,312 @@ + /*! + * jQuery Simulate v@VERSION - simulate browser mouse and keyboard events + * https://github.com/jquery/jquery-simulate + * + * Copyright 2012 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * Date: @DATE + */ + +;(function( $, undefined ) { + +var rkeyEvent = /^key/, + rmouseEvent = /^(?:mouse|contextmenu)|click/; + +$.fn.simulate = function( type, options ) { + return this.each(function() { + new $.simulate( this, type, options ); + }); +}; + +$.simulate = function( elem, type, options ) { + var method = $.camelCase( "simulate-" + type ); + + this.target = elem; + this.options = options; + + if ( this[ method ] ) { + this[ method ](); + } else { + this.simulateEvent( elem, type, options ); + } +}; + +$.extend( $.simulate, { + + keyCode: { + BACKSPACE: 8, + COMMA: 188, + DELETE: 46, + DOWN: 40, + END: 35, + ENTER: 13, + ESCAPE: 27, + HOME: 36, + LEFT: 37, + NUMPAD_ADD: 107, + NUMPAD_DECIMAL: 110, + NUMPAD_DIVIDE: 111, + NUMPAD_ENTER: 108, + NUMPAD_MULTIPLY: 106, + NUMPAD_SUBTRACT: 109, + PAGE_DOWN: 34, + PAGE_UP: 33, + PERIOD: 190, + RIGHT: 39, + SPACE: 32, + TAB: 9, + UP: 38 + }, + + buttonCode: { + LEFT: 0, + MIDDLE: 1, + RIGHT: 2 + } +}); + +$.extend( $.simulate.prototype, { + + simulateEvent: function( elem, type, options ) { + var event = this.createEvent( type, options ); + this.dispatchEvent( elem, type, event, options ); + }, + + createEvent: function( type, options ) { + if ( rkeyEvent.test( type ) ) { + return this.keyEvent( type, options ); + } + + if ( rmouseEvent.test( type ) ) { + return this.mouseEvent( type, options ); + } + }, + + mouseEvent: function( type, options ) { + var event, eventDoc, doc, body; + options = $.extend({ + bubbles: true, + cancelable: (type !== "mousemove"), + view: window, + detail: 0, + screenX: 0, + screenY: 0, + clientX: 1, + clientY: 1, + ctrlKey: false, + altKey: false, + shiftKey: false, + metaKey: false, + button: 0, + relatedTarget: undefined + }, options ); + + if ( document.createEvent ) { + event = document.createEvent( "MouseEvents" ); + event.initMouseEvent( type, options.bubbles, options.cancelable, + options.view, options.detail, + options.screenX, options.screenY, options.clientX, options.clientY, + options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, + options.button, options.relatedTarget || document.body.parentNode ); + + // IE 9+ creates events with pageX and pageY set to 0. + // Trying to modify the properties throws an error, + // so we define getters to return the correct values. + if ( event.pageX === 0 && event.pageY === 0 && Object.defineProperty ) { + eventDoc = event.relatedTarget.ownerDocument || document; + doc = eventDoc.documentElement; + body = eventDoc.body; + + Object.defineProperty( event, "pageX", { + get: function() { + return options.clientX + + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - + ( doc && doc.clientLeft || body && body.clientLeft || 0 ); + } + }); + Object.defineProperty( event, "pageY", { + get: function() { + return options.clientY + + ( doc && doc.scrollTop || body && body.scrollTop || 0 ) - + ( doc && doc.clientTop || body && body.clientTop || 0 ); + } + }); + } + } else if ( document.createEventObject ) { + event = document.createEventObject(); + $.extend( event, options ); + // standards event.button uses constants defined here: http://msdn.microsoft.com/en-us/library/ie/ff974877(v=vs.85).aspx + // old IE event.button uses constants defined here: http://msdn.microsoft.com/en-us/library/ie/ms533544(v=vs.85).aspx + // so we actually need to map the standard back to oldIE + event.button = { + 0: 1, + 1: 4, + 2: 2 + }[ event.button ] || event.button; + } + + return event; + }, + + keyEvent: function( type, options ) { + var event; + options = $.extend({ + bubbles: true, + cancelable: true, + view: window, + ctrlKey: false, + altKey: false, + shiftKey: false, + metaKey: false, + keyCode: 0, + charCode: undefined + }, options ); + + if ( document.createEvent ) { + try { + event = document.createEvent( "KeyEvents" ); + event.initKeyEvent( type, options.bubbles, options.cancelable, options.view, + options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, + options.keyCode, options.charCode ); + // initKeyEvent throws an exception in WebKit + // see: http://stackoverflow.com/questions/6406784/initkeyevent-keypress-only-works-in-firefox-need-a-cross-browser-solution + // and also https://bugs.webkit.org/show_bug.cgi?id=13368 + // fall back to a generic event until we decide to implement initKeyboardEvent + } catch( err ) { + event = document.createEvent( "Events" ); + event.initEvent( type, options.bubbles, options.cancelable ); + $.extend( event, { + view: options.view, + ctrlKey: options.ctrlKey, + altKey: options.altKey, + shiftKey: options.shiftKey, + metaKey: options.metaKey, + keyCode: options.keyCode, + charCode: options.charCode + }); + } + } else if ( document.createEventObject ) { + event = document.createEventObject(); + $.extend( event, options ); + } + + if ( !!/msie [\w.]+/.exec( navigator.userAgent.toLowerCase() ) || (({}).toString.call( window.opera ) === "[object Opera]") ) { + event.keyCode = (options.charCode > 0) ? options.charCode : options.keyCode; + event.charCode = undefined; + } + + return event; + }, + + dispatchEvent: function( elem, type, event ) { + if ( elem.dispatchEvent ) { + elem.dispatchEvent( event ); + } else if ( elem.fireEvent ) { + elem.fireEvent( "on" + type, event ); + } + }, + + simulateFocus: function() { + var focusinEvent, + triggered = false, + element = $( this.target ); + + function trigger() { + triggered = true; + } + + element.bind( "focus", trigger ); + element[ 0 ].focus(); + + if ( !triggered ) { + focusinEvent = $.Event( "focusin" ); + focusinEvent.preventDefault(); + element.trigger( focusinEvent ); + element.triggerHandler( "focus" ); + } + element.unbind( "focus", trigger ); + }, + + simulateBlur: function() { + var focusoutEvent, + triggered = false, + element = $( this.target ); + + function trigger() { + triggered = true; + } + + element.bind( "blur", trigger ); + element[ 0 ].blur(); + + // blur events are async in IE + setTimeout(function() { + // IE won't let the blur occur if the window is inactive + if ( element[ 0 ].ownerDocument.activeElement === element[ 0 ] ) { + element[ 0 ].ownerDocument.body.focus(); + } + + // Firefox won't trigger events if the window is inactive + // IE doesn't trigger events if we had to manually focus the body + if ( !triggered ) { + focusoutEvent = $.Event( "focusout" ); + focusoutEvent.preventDefault(); + element.trigger( focusoutEvent ); + element.triggerHandler( "blur" ); + } + element.unbind( "blur", trigger ); + }, 1 ); + } +}); + + + +/** complex events **/ + +function findCenter( elem ) { + var offset, + document = $( elem.ownerDocument ); + elem = $( elem ); + offset = elem.offset(); + + return { + x: offset.left + elem.outerWidth() / 2 - document.scrollLeft(), + y: offset.top + elem.outerHeight() / 2 - document.scrollTop() + }; +} + +$.extend( $.simulate.prototype, { + simulateDrag: function() { + var i = 0, + target = this.target, + options = this.options, + center = findCenter( target ), + x = Math.floor( center.x ), + y = Math.floor( center.y ), + dx = options.dx || 0, + dy = options.dy || 0, + moves = options.moves || 3, + coord = { clientX: x, clientY: y }; + + this.simulateEvent( target, "mousedown", coord ); + + for ( ; i < moves ; i++ ) { + x += dx / moves; + y += dy / moves; + + coord = { + clientX: Math.round( x ), + clientY: Math.round( y ) + }; + + this.simulateEvent( document, "mousemove", coord ); + } + + this.simulateEvent( target, "mouseup", coord ); + this.simulateEvent( target, "click", coord ); + } +}); + +})( jQuery ); \ No newline at end of file diff --git a/lms/templates/vert_module.html b/lms/templates/vert_module.html index a153773020..51e12c0e60 100644 --- a/lms/templates/vert_module.html +++ b/lms/templates/vert_module.html @@ -1,7 +1,15 @@ +<%! +from django.utils.translation import ugettext as _ +%>
-% for idx, item in enumerate(items): -
- ${item['content']} -
-% endfor +
    + % for idx, item in enumerate(items): +
  1. +
    + + ${item['content']} +
    +
  2. + % endfor +