From fb150bcf64d0553b7f263102746139584febbea9 Mon Sep 17 00:00:00 2001 From: cahrens Date: Sat, 5 Apr 2014 17:01:51 -0400 Subject: [PATCH 01/12] 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 +
From fd7142bcde3895877f98c7766bfba1aa911e3101 Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Wed, 9 Apr 2014 13:35:01 -0400 Subject: [PATCH 02/12] dnd container page styling --- cms/static/js/views/container.js | 7 ++- cms/static/sass/_base.scss | 2 +- cms/static/sass/elements/_controls.scss | 12 +++- cms/static/sass/elements/_xblocks.scss | 5 +- cms/static/sass/views/_container.scss | 55 ++++++++++++++++++- cms/templates/studio_xblock_wrapper.html | 3 + .../sass/course/courseware/_courseware.scss | 2 +- lms/templates/vert_module.html | 3 +- 8 files changed, 77 insertions(+), 12 deletions(-) diff --git a/cms/static/js/views/container.js b/cms/static/js/views/container.js index 4dd192d9d8..151d89f527 100644 --- a/cms/static/js/views/container.js +++ b/cms/static/js/views/container.js @@ -66,13 +66,14 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", oldParent = parent; } }, - helper: "clone", + helper: "original", opacity: '0.5', placeholder: 'component-placeholder', forcePlaceholderSize: true, axis: 'y', items: '> .vertical-element', - connectWith: ".vertical-container" + connectWith: ".vertical-container", + tolerance: "pointer" }); }, @@ -114,4 +115,4 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", }); return ContainerView; - }); // end define(); \ No newline at end of file + }); // end define(); diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index 2385ab7d77..831c306f97 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -4,7 +4,7 @@ // basic setup html { font-size: 62.5%; - overflow-y: scroll; + height: 102%; // force scrollbar to prevent jump when scroll appears, cannot use overflow because it breaks drag } body { diff --git a/cms/static/sass/elements/_controls.scss b/cms/static/sass/elements/_controls.scss index cf8c114e49..3e2a423a78 100644 --- a/cms/static/sass/elements/_controls.scss +++ b/cms/static/sass/elements/_controls.scss @@ -227,11 +227,12 @@ .action-item { display: inline-block; + vertical-align: middle; .action-button { + display: block; border-radius: 3px; padding: ($baseline/4) ($baseline/2); - height: ($baseline*1.5); color: $gray-l1; &:hover { @@ -248,6 +249,15 @@ background-color: $gray-l1; } } + + .drag-handle { + display: block; + float: none; + height: ($baseline*1.2); + width: ($baseline); + margin: 0; + background: transparent url("../img/drag-handles.png") no-repeat right center; + } } } diff --git a/cms/static/sass/elements/_xblocks.scss b/cms/static/sass/elements/_xblocks.scss index 38157c8aa0..43c49f753a 100644 --- a/cms/static/sass/elements/_xblocks.scss +++ b/cms/static/sass/elements/_xblocks.scss @@ -19,6 +19,7 @@ @include box-sizing(border-box); @include ui-flexbox(); @extend %ui-align-center-flex; + justify-content: space-between; border-bottom: 1px solid $gray-l4; border-radius: ($baseline/5) ($baseline/5) 0 0; min-height: ($baseline*2.5); @@ -30,14 +31,14 @@ @extend %ui-justify-left-flex; @include ui-flexbox(); width: flex-grid(6,12); - vertical-align: top; + vertical-align: middle; } .header-actions { @include ui-flexbox(); @extend %ui-justify-right-flex; width: flex-grid(6,12); - vertical-align: top; + vertical-align: middle; } } } diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss index a6a9147b87..cc2ca92a11 100644 --- a/cms/static/sass/views/_container.scss +++ b/cms/static/sass/views/_container.scss @@ -7,7 +7,7 @@ // ==================== // UI: container page view -body.view-container { +.view-container { .mast { border-bottom: none; @@ -97,7 +97,58 @@ body.view-container { } // UI: xblock rendering -body.view-container .content-primary { +body.view-container .content-primary { + + // dragging bits + .ui-sortable-helper { + + article { + display: none; + } + } + + .component-placeholder { + height: ($baseline*2.5); + opacity: .5; + margin: $baseline; + background-color: $gray-l5; + border-radius: ($baseline/2); + border: 2px dashed $gray-l2; + } + + .vert-mod { + + // min-height to allow drop when empty + .vertical-container { + min-height: ($baseline*2.5); + } + + .vert { + position: relative; + + .drag-handle { + display: none; // only show when vert is draggable + position: absolute; + top: 0; + right: ($baseline/2); // equal to margin on component + width: ($baseline*1.5); + height: ($baseline*2.5); + margin: 0; + background: transparent url("../img/drag-handles.png") no-repeat scroll center center; + } + } + + .is-draggable { + + .xblock-header { + padding-right: ($baseline*1.5); // make room for drag handle + } + + .drag-handle { + display: block; + } + } + } .wrapper-xblock { @extend %wrap-xblock; diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index f656006856..bc636190d3 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -36,6 +36,9 @@ % endif +
  • + +
  • diff --git a/lms/static/sass/course/courseware/_courseware.scss b/lms/static/sass/course/courseware/_courseware.scss index 051b98f088..2b590f92d8 100644 --- a/lms/static/sass/course/courseware/_courseware.scss +++ b/lms/static/sass/course/courseware/_courseware.scss @@ -14,7 +14,7 @@ html.video-fullscreen{ .wrap-instructor-info { margin: ($baseline/2) ($baseline/4) 0 0; overflow: hidden; - + &.studio-view { position: relative; top: -($baseline/2); diff --git a/lms/templates/vert_module.html b/lms/templates/vert_module.html index 51e12c0e60..549ae4e032 100644 --- a/lms/templates/vert_module.html +++ b/lms/templates/vert_module.html @@ -4,9 +4,8 @@ from django.utils.translation import ugettext as _
      % for idx, item in enumerate(items): -
    1. +
    2. - ${item['content']}
    3. From c1fd0ca34ed58de8c89091f8d30ac2aaf0ff8e2d Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Thu, 10 Apr 2014 10:40:22 -0400 Subject: [PATCH 03/12] Switch the container page to use the new view --- cms/static/js/views/pages/container.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 9354af97c1..cc4f96ba09 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -2,8 +2,8 @@ * XBlockContainerView is used to display an xblock which has children, and allows the * user to interact with the children. */ -define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt", "js/views/baseview", "js/views/xblock", "js/views/modals/edit_xblock", "js/models/xblock_info"], - function ($, _, gettext, NotificationView, PromptView, BaseView, XBlockView, EditXBlockModal, XBlockInfo) { +define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt", "js/views/baseview", "js/views/container", "js/views/xblock", "js/views/modals/edit_xblock", "js/models/xblock_info"], + function ($, _, gettext, NotificationView, PromptView, BaseView, ContainerView, XBlockView, EditXBlockModal, XBlockInfo) { var XBlockContainerView = BaseView.extend({ // takes XBlockInfo as a model @@ -13,7 +13,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js initialize: function() { BaseView.prototype.initialize.call(this); this.noContentElement = this.$('.no-container-content'); - this.xblockView = new XBlockView({ + this.xblockView = new ContainerView({ el: this.$('.wrapper-xblock'), model: this.model, view: this.view @@ -184,4 +184,3 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js return XBlockContainerView; }); // end define(); - From 8e00f6dc3b4205ca7e938cb3520fd792de9001ca Mon Sep 17 00:00:00 2001 From: cahrens Date: Mon, 14 Apr 2014 11:41:05 -0400 Subject: [PATCH 04/12] Restore the ability to drag at any level inside a vertical and clean up templates. --- cms/templates/studio_xblock_wrapper.html | 3 --- common/lib/xmodule/xmodule/vertical_module.py | 18 +++++++++++++++++- lms/templates/vert_module.html | 17 +++++------------ lms/templates/vert_module_studio_view.html | 15 +++++++++++++++ 4 files changed, 37 insertions(+), 16 deletions(-) create mode 100644 lms/templates/vert_module_studio_view.html diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index bc636190d3..f656006856 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -36,9 +36,6 @@ % endif -
    4. - -
    diff --git a/common/lib/xmodule/xmodule/vertical_module.py b/common/lib/xmodule/xmodule/vertical_module.py index 0053cb5ca1..f18153c066 100644 --- a/common/lib/xmodule/xmodule/vertical_module.py +++ b/common/lib/xmodule/xmodule/vertical_module.py @@ -17,6 +17,22 @@ class VerticalModule(VerticalFields, XModule): ''' Layout module for laying out submodules vertically.''' def student_view(self, context): + # When rendering a Studio preview, use a different template to support drag and drop. + if context and context['runtime_type'] == 'studio': + return self.studio_preview_view(context) + + return self.render_view(context, 'vert_module.html') + + def studio_preview_view(self, context): + """ + Renders the Studio preview view, which supports drag and drop. + """ + return self.render_view(context, 'vert_module_studio_view.html') + + def render_view(self, context, template_name): + """ + Helper method for rendering student_view and the Studio version. + """ fragment = Fragment() contents = [] @@ -29,7 +45,7 @@ class VerticalModule(VerticalFields, XModule): 'content': rendered_child.content }) - fragment.add_content(self.system.render_template('vert_module.html', { + fragment.add_content(self.system.render_template(template_name, { 'items': contents })) return fragment diff --git a/lms/templates/vert_module.html b/lms/templates/vert_module.html index 549ae4e032..a153773020 100644 --- a/lms/templates/vert_module.html +++ b/lms/templates/vert_module.html @@ -1,14 +1,7 @@ -<%! -from django.utils.translation import ugettext as _ -%>
    -
      - % for idx, item in enumerate(items): -
    1. -
      - ${item['content']} -
      -
    2. - % endfor -
    +% for idx, item in enumerate(items): +
    + ${item['content']} +
    +% endfor
    diff --git a/lms/templates/vert_module_studio_view.html b/lms/templates/vert_module_studio_view.html new file mode 100644 index 0000000000..b8bab17950 --- /dev/null +++ b/lms/templates/vert_module_studio_view.html @@ -0,0 +1,15 @@ +<%! +from django.utils.translation import ugettext as _ +%> +
    +
      + % for idx, item in enumerate(items): +
    1. +
      + + ${item['content']} +
      +
    2. + % endfor +
    +
    From 37da39999738393ab4fa04308408e29363c3b45c Mon Sep 17 00:00:00 2001 From: cahrens Date: Fri, 18 Apr 2014 09:43:23 -0400 Subject: [PATCH 05/12] Update unit test based on changes in master. --- CHANGELOG.rst | 2 + cms/static/js/spec/views/container_spec.js | 118 ++++--- .../js/spec/views/modals/edit_xblock_spec.js | 2 +- cms/static/js/views/container.js | 24 +- .../js/mock/mock-container-xblock.underscore | 309 ++++++++++++------ .../js/mock/mock-container.underscore | 209 ------------ 6 files changed, 284 insertions(+), 380 deletions(-) delete mode 100644 cms/templates/js/mock/mock-container.underscore diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b3a6331f7f..79de8c09f4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Studio: Add drag-and-drop support to the container page. STUD-1309. + Common: Add extensible third-party auth module. Blades: Handle situation if no response were sent from XQueue to LMS in Matlab diff --git a/cms/static/js/spec/views/container_spec.js b/cms/static/js/spec/views/container_spec.js index 53de72a3be..dd13ef46a5 100644 --- a/cms/static/js/spec/views/container_spec.js +++ b/cms/static/js/spec/views/container_spec.js @@ -1,4 +1,4 @@ -define([ "jquery", "js/spec/create_sinon", "URI", "js/views/container", "js/models/xblock_info", +define([ "jquery", "js/spec_helpers/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) { @@ -7,24 +7,26 @@ define([ "jquery", "js/spec/create_sinon", "URI", "js/views/container", "js/mode describe("Supports reordering components", function () { - var model, containerView, mockContainerHTML, respondWithMockXBlockFragment; + var model, containerView, mockContainerHTML, respondWithMockXBlockFragment, + init, dragHandle, verifyRequest, verifyNumReorderCalls, respondToRequest, - // 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"; + rootLocator = 'testCourse/branch/draft/split_test/splitFFF', + containerTestUrl = '/xblock/' + rootLocator, - 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"; + groupAUrl = "/xblock/locator-group-A", + groupA = "locator-group-A", + groupAComponent1 = "locator-component-A1", + groupAComponent2 = "locator-component-A2", + groupAComponent3 = "locator-component-A3", - 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"; + groupBUrl = "/xblock/locator-group-B", + groupB = "locator-group-B", + groupBComponent1 = "locator-component-B1", + groupBComponent2 = "locator-component-B2", + groupBComponent3 = "locator-component-B3"; - // TODO: switch to using Andy's mock container view files (which uses mock xblocks). - mockContainerHTML = readFixtures('mock/mock-container.underscore'); + rootLocator = 'testCourse/branch/draft/split_test/splitFFF'; + mockContainerHTML = readFixtures('mock/mock-container-xblock.underscore'); respondWithMockXBlockFragment = function (requests, response) { var requestIndex = requests.length - 1; @@ -32,15 +34,17 @@ define([ "jquery", "js/spec/create_sinon", "URI", "js/views/container", "js/mode }; beforeEach(function () { + setFixtures('
    '); model = new XBlockInfo({ - id: 'testCourse/branch/draft/split_test/splitFFF', + id: rootLocator, display_name: 'Test AB Test', category: 'split_test' }); containerView = new ContainerView({ model: model, - view: 'container_preview' + view: 'container_preview', + el: $('.wrapper-xblock') }); }); @@ -48,7 +52,7 @@ define([ "jquery", "js/spec/create_sinon", "URI", "js/views/container", "js/mode containerView.remove(); }); - var init = function (caller) { + init = function (caller) { var requests = create_sinon.requests(caller); containerView.render(); @@ -61,27 +65,29 @@ define([ "jquery", "js/spec/create_sinon", "URI", "js/views/container", "js/mode return requests; }; - var dragHandle = function (index, dy) { - containerView.$el.find(".drag-handle:eq(" + index + ")").simulate("drag", {dy: dy}); + dragHandle = function (index, dy) { + var handle = containerView.$(".drag-handle:eq(" + index + ")"); + handle.simulate("drag", {dy: dy}); }; - var verifyRequest = function (requests, reorderCallIndex, expectedURL, expectedChildren) { + verifyRequest = function (requests, reorderCallIndex, expectedURL, expectedChildren) { + var request, children, i; // 0th call is the response to the initial render call to get HTML. - var request = requests[reorderCallIndex + 1]; + request = requests[reorderCallIndex + 1]; expect(request.url).toEqual(expectedURL); - var children = (JSON.parse(request.requestBody)).children; + children = (JSON.parse(request.requestBody)).children; expect(children.length).toEqual(expectedChildren.length); - for (var i = 0; i < children.length; i++) { + for (i = 0; i < children.length; i++) { expect(children[i]).toEqual(expectedChildren[i]); } }; - var verifyNumReorderCalls = function (requests, expectedCalls) { + 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) { + 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); }; @@ -89,68 +95,70 @@ define([ "jquery", "js/spec/create_sinon", "URI", "js/views/container", "js/mode 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); + dragHandle(2, 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); + // Drag the first component in Group A to the end + dragHandle(2, 80); respondToRequest(requests, 0, 200); verifyNumReorderCalls(requests, 1); - verifyRequest(requests, 0, groupAUrl, [groupAVideo, groupAText]); + verifyRequest(requests, 0, groupAUrl, [groupAComponent2, groupAComponent3, groupAComponent1]); }); 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); + // Drag the first component in Group A into the second group. + dragHandle(2, 300); 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]); + verifyRequest(requests, 0, groupBUrl, + [groupBComponent1, groupBComponent2, groupAComponent1, groupBComponent3]); + verifyRequest(requests, 1, groupAUrl, [groupAComponent2, groupAComponent3]); }); 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); + // Drag the first component in Group A into the second group. + dragHandle(2, 300); 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]); + verifyRequest(requests, 0, groupBUrl, + [groupBComponent1, groupBComponent2, groupAComponent1, groupBComponent3]); }); it('can swap group A and group B', function () { var requests = init(this); // Drag Group B before group A. - dragHandle(3, -200); + dragHandle(5, -300); respondToRequest(requests, 0, 200); verifyNumReorderCalls(requests, 1); - verifyRequest(requests, 0, splitTestUrl, [groupB, groupA]); + verifyRequest(requests, 0, containerTestUrl, [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); + dragHandle(2, -40); respondToRequest(requests, 0, 200); respondToRequest(requests, 1, 200); verifyNumReorderCalls(requests, 2); - verifyRequest(requests, 0, splitTestUrl, [groupAText, groupA, groupB]); - verifyRequest(requests, 1, groupAUrl, [groupAVideo]); + verifyRequest(requests, 0, containerTestUrl, [groupAComponent1, groupA, groupB]); + verifyRequest(requests, 1, groupAUrl, [groupAComponent2, groupAComponent3]); - // Drag Group A (only contains video now) into Group B. + // Drag Group A 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]); + verifyRequest(requests, 2, groupBUrl, [groupBComponent1, groupA, groupBComponent2]); + verifyRequest(requests, 3, containerTestUrl, [groupAComponent1, groupB]); }); describe("Shows a saving message", function () { @@ -163,13 +171,16 @@ define([ "jquery", "js/spec/create_sinon", "URI", "js/views/container", "js/mode }); 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); + var requests, savingOptions; + requests = init(this); + + // Drag the first component in Group A into the second group. + dragHandle(2, 200); + expect(savingSpies.constructor).toHaveBeenCalled(); expect(savingSpies.show).toHaveBeenCalled(); expect(savingSpies.hide).not.toHaveBeenCalled(); - var savingOptions = savingSpies.constructor.mostRecentCall.args[0]; + savingOptions = savingSpies.constructor.mostRecentCall.args[0]; expect(savingOptions.title).toMatch(/Saving/); respondToRequest(requests, 0, 200); @@ -181,8 +192,10 @@ define([ "jquery", "js/spec/create_sinon", "URI", "js/views/container", "js/mode 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); + + // Drag the first component in Group A into the second group. + dragHandle(2, 200); + expect(savingSpies.constructor).toHaveBeenCalled(); expect(savingSpies.show).toHaveBeenCalled(); expect(savingSpies.hide).not.toHaveBeenCalled(); @@ -195,4 +208,5 @@ define([ "jquery", "js/spec/create_sinon", "URI", "js/views/container", "js/mode }); }); }); - }); \ No newline at end of file + }); +147 \ No newline at end of file diff --git a/cms/static/js/spec/views/modals/edit_xblock_spec.js b/cms/static/js/spec/views/modals/edit_xblock_spec.js index e58759dea3..ecc12b43f6 100644 --- a/cms/static/js/spec/views/modals/edit_xblock_spec.js +++ b/cms/static/js/spec/views/modals/edit_xblock_spec.js @@ -12,7 +12,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers beforeEach(function () { edit_helpers.installEditTemplates(); - appendSetFixtures('
    '); + appendSetFixtures('
    '); model = new XBlockInfo({ id: 'testCourse/branch/draft/block/verticalFFF', display_name: 'Test Unit', diff --git a/cms/static/js/views/container.js b/cms/static/js/views/container.js index 151d89f527..c3cabd60ce 100644 --- a/cms/static/js/views/container.js +++ b/cms/static/js/views/container.js @@ -4,8 +4,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", xblockReady: function () { XBlockView.prototype.xblockReady.call(this); - var verticalContainer = $(this.$el).find('.vertical-container'), - alreadySortable = $(this.$el).find('.ui-sortable'), + var verticalContainer = this.$('.vertical-container'), + alreadySortable = this.$('.ui-sortable'), newParent, oldParent, self = this; @@ -16,6 +16,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", handle: '.drag-handle', stop: function (event, ui) { + var saving, hideSaving, removeFromParent; + console.log('stop'); if (oldParent === undefined) { @@ -24,12 +26,12 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", return; } - var saving = new NotificationView.Mini({ + saving = new NotificationView.Mini({ title: gettext('Saving…') }); saving.show(); - var hideSaving = function () { + hideSaving = function () { saving.hide(); }; @@ -37,12 +39,11 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", // add to new container before deleting from old to // avoid creating an orphan if the addition fails. if (newParent) { - var removeFromParent = oldParent; + removeFromParent = oldParent; self.reorder(newParent, function () { self.reorder(removeFromParent, hideSaving); }); - } - else { + } else { // No new parent, only reordering within same container. self.reorder(oldParent, hideSaving); } @@ -60,8 +61,7 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", if (ui.sender) { // Move to a new container (the addition part). newParent = parent; - } - else { + } else { // Reorder inside a container, or deletion when moving to new container. oldParent = parent; } @@ -79,16 +79,18 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", }, reorder: function (targetParent, successCallback) { + var children, childLocators; + 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 () { + children = targetParent.find('.wrapper-xblock').filter(function () { var parent = $(this).parent().closest('.wrapper-xblock'); return parent.data('locator') === targetParent.data('locator'); }); - var childLocators = _.map( + childLocators = _.map( children, function (child) { return $(child).data('locator'); diff --git a/cms/templates/js/mock/mock-container-xblock.underscore b/cms/templates/js/mock/mock-container-xblock.underscore index 16f3b9456b..cf6f9c660a 100644 --- a/cms/templates/js/mock/mock-container-xblock.underscore +++ b/cms/templates/js/mock/mock-container-xblock.underscore @@ -1,127 +1,222 @@
    -
    +
    -
    +
      +
    1. +
      + +
      +
      +
        +
      1. +
        + +
        +
        -
        -
        -
        +
        +
        +
        +
          +
        1. +
          + +
          +
          +
          +
            +
          • +
          • +
          • +
          • +
          • +
          • +
          +
          +
          +
          +
          +
          -
          -
          +
        2. +
        3. +
          + +
          -
          -
          -
          -
          - -
          - -
          -
          -
            -
          • -
          • -
          • -
          +
          +
          +
            +
          • +
          • +
          • +
          • +
          • +
          • +
          +
          +
          +
          +
          +
          +
        4. +
        5. +
          + +
          +
          +
          +
            +
          • +
          • +
          • +
          • +
          • +
          • +
          +
          +
          +
          +
          +
          +
        6. +
        - -
        -
        - -
        - -
        -
        -
          -
        • -
        • -
        • -
        -
        -
        -
        -
        - -
        - -
        -
        -
          -
        • -
        • -
        • -
        -
        -
        -
        -
        -
        +
      +
    + - - - + +
  • +
    + +
    +
    -
    -
    +
    +
    +
    +
      +
    1. +
      + +
      -
      -
      -
      -
      +
      +
      +
        +
      • +
      • +
      • +
      • +
      • +
      • +
      +
      +
      +
      +
      +
      +
    2. +
    3. +
      + +
      -
      +
      +
      +
        +
      • +
      • +
      • +
      • +
      • +
      • +
      +
      +
      +
      +
      +
      +
    4. +
    5. +
      + +
      -
      -
      -
        -
      • -
      • -
      • -
      +
      +
      +
        +
      • +
      • +
      • +
      • +
      • +
      • +
      +
      +
      +
      +
      +
      +
    6. +
    - -
    -
    - -
    - -
    -
    -
      -
    • -
    • -
    • -
    -
    -
    -
    -
    - -
    - -
    -
    -
      -
    • -
    • -
    • -
    -
    -
    -
    -
    -
    + + + - - - +
  • + + - - + + diff --git a/cms/templates/js/mock/mock-container.underscore b/cms/templates/js/mock/mock-container.underscore deleted file mode 100644 index e69a12e358..0000000000 --- a/cms/templates/js/mock/mock-container.underscore +++ /dev/null @@ -1,209 +0,0 @@ -
    -
    -
    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. -
    -
    -
    -
    -
    - - From f3a23f3973b5c824b101061def1326d7ed669587 Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Wed, 30 Apr 2014 14:58:40 -0400 Subject: [PATCH 06/12] Only show drag handles in draft mode --- cms/djangoapps/contentstore/views/item.py | 26 ++++- .../views/tests/test_container.py | 39 +++++++- cms/static/js/spec/views/container_spec.js | 30 +++--- cms/templates/component.html | 5 +- cms/templates/container_xblock_component.html | 3 +- .../xmodule/modulestore/mongo/draft.py | 2 +- common/lib/xmodule/xmodule/vertical_module.py | 3 +- .../acceptance/tests/test_studio_container.py | 98 +++++++++++++++++++ lms/templates/vert_module_studio_view.html | 2 + 9 files changed, 185 insertions(+), 23 deletions(-) create mode 100644 common/test/acceptance/tests/test_studio_container.py diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 8e0e5fc14a..6916120caf 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -35,6 +35,7 @@ from ..utils import get_modulestore from .access import has_course_access from .helpers import _xmodule_recurse from contentstore.utils import compute_publish_state, PublishState +from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES from contentstore.views.preview import get_preview_fragment from edxmako.shortcuts import render_to_string from models.settings.course_grading import CourseGradingModel @@ -193,6 +194,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v if 'application/json' in accept_header: store = get_modulestore(old_location) component = store.get_item(old_location) + is_read_only = _xblock_is_read_only(component) # wrap the generated fragment in the xmodule_editor div so that the javascript # can bind to it correctly @@ -212,12 +214,18 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v store.update_item(component, None) elif view_name == 'student_view' and component.has_children: + context = { + 'runtime_type': 'studio', + 'container_view': False, + 'read_only': is_read_only, + 'root_xblock': component, + } # For non-leaf xblocks on the unit page, show the special rendering # which links to the new container page. html = render_to_string('container_xblock_component.html', { + 'xblock_context': context, 'xblock': component, 'locator': locator, - 'reordering_enabled': True, }) return JsonResponse({ 'html': html, @@ -225,8 +233,6 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v }) elif view_name in ('student_view', 'container_preview'): is_container_view = (view_name == 'container_preview') - component_publish_state = compute_publish_state(component) - is_read_only_view = component_publish_state == PublishState.public # Only show the new style HTML for the container view, i.e. for non-verticals # Note: this special case logic can be removed once the unit page is replaced @@ -234,7 +240,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v context = { 'runtime_type': 'studio', 'container_view': is_container_view, - 'read_only': is_read_only_view, + 'read_only': is_read_only, 'root_xblock': component, } @@ -244,6 +250,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v # into the preview fragment, so we don't want to add another header here. if not is_container_view: fragment.content = render_to_string('component.html', { + 'xblock_context': context, 'preview': fragment.content, 'label': component.display_name or component.scope_ids.block_type, }) @@ -263,6 +270,17 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v return HttpResponse(status=406) +def _xblock_is_read_only(xblock): + """ + Returns true if the specified xblock is read-only, meaning that it cannot be edited. + """ + # We allow direct editing of xblocks in DIRECT_ONLY_CATEGORIES (for example, static pages). + if xblock.category in DIRECT_ONLY_CATEGORIES: + return False + component_publish_state = compute_publish_state(xblock) + return component_publish_state == PublishState.public + + def _save_item(request, usage_loc, item_location, data=None, children=None, metadata=None, nullout=None, grader_type=None, publish=None): """ diff --git a/cms/djangoapps/contentstore/views/tests/test_container.py b/cms/djangoapps/contentstore/views/tests/test_container.py index 3c19efc896..a18e0552a8 100644 --- a/cms/djangoapps/contentstore/views/tests/test_container.py +++ b/cms/djangoapps/contentstore/views/tests/test_container.py @@ -2,10 +2,12 @@ Unit tests for the container view. """ +import json + from contentstore.tests.utils import CourseTestCase from contentstore.utils import compute_publish_state, PublishState from contentstore.views.helpers import xblock_studio_url -from xmodule.modulestore.django import modulestore +from xmodule.modulestore.django import loc_mapper, modulestore from xmodule.modulestore.tests.factories import ItemFactory @@ -56,7 +58,6 @@ class ContainerViewTestCase(CourseTestCase): parent_location=published_xblock_with_child.location, category="html", display_name="Child HTML" ) - draft_xblock_with_child = modulestore('draft').convert_to_draft(published_xblock_with_child.location) branch_name = "MITx.999.Robot_Super_Course/branch/draft/block" self._test_html_content( published_xblock_with_child, @@ -73,6 +74,11 @@ class ContainerViewTestCase(CourseTestCase): r'Wrapper' ).format(branch_name=branch_name) ) + + # Now make the unit and its children into a draft and validate the container again + modulestore('draft').convert_to_draft(self.vertical.location) + modulestore('draft').convert_to_draft(self.child_vertical.location) + draft_xblock_with_child = modulestore('draft').convert_to_draft(published_xblock_with_child.location) self._test_html_content( draft_xblock_with_child, branch_name=branch_name, @@ -112,3 +118,32 @@ class ContainerViewTestCase(CourseTestCase): branch_name=branch_name ) self.assertIn(expected_unit_link, html) + + def test_container_preview_html(self): + """ + Verify that an xblock returns the expected HTML for a container preview + """ + # First verify that the behavior is correct with a published container + self._test_preview_html(self.child_vertical) + + # Now make the unit and its children into a draft and validate the preview again + modulestore('draft').convert_to_draft(self.vertical.location) + draft_container = modulestore('draft').convert_to_draft(self.child_vertical.location) + self._test_preview_html(draft_container) + + def _test_preview_html(self, xblock): + locator = loc_mapper().translate_location(self.course.id, xblock.location, published=False) + publish_state = compute_publish_state(xblock) + preview_url = '/xblock/{locator}/container_preview'.format(locator=locator) + + resp = self.client.get(preview_url, HTTP_ACCEPT='application/json') + self.assertEqual(resp.status_code, 200) + resp_content = json.loads(resp.content) + html = resp_content['html'] + + # Verify that there are no drag handles for public pages + drag_handle_html = '' + if publish_state == PublishState.public: + self.assertNotIn(drag_handle_html, html) + else: + self.assertIn(drag_handle_html, html) diff --git a/cms/static/js/spec/views/container_spec.js b/cms/static/js/spec/views/container_spec.js index dd13ef46a5..45e76ed74a 100644 --- a/cms/static/js/spec/views/container_spec.js +++ b/cms/static/js/spec/views/container_spec.js @@ -8,7 +8,8 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", describe("Supports reordering components", function () { var model, containerView, mockContainerHTML, respondWithMockXBlockFragment, - init, dragHandle, verifyRequest, verifyNumReorderCalls, respondToRequest, + init, dragHandleVertically, dragHandleOver, verifyRequest, verifyNumReorderCalls, + respondToRequest, rootLocator = 'testCourse/branch/draft/split_test/splitFFF', containerTestUrl = '/xblock/' + rootLocator, @@ -65,11 +66,18 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", return requests; }; - dragHandle = function (index, dy) { + dragHandleVertically = function (index, dy) { var handle = containerView.$(".drag-handle:eq(" + index + ")"); handle.simulate("drag", {dy: dy}); }; + dragHandleOver = function (index, targetElement) { + var handle = containerView.$(".drag-handle:eq(" + index + ")"), + dy = handle.y - targetElement.y; + + handle.simulate("drag", {dy: dy}); + }; + verifyRequest = function (requests, reorderCallIndex, expectedURL, expectedChildren) { var request, children, i; // 0th call is the response to the initial render call to get HTML. @@ -95,14 +103,14 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", 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(2, 5); + dragHandleVertically(2, 5); verifyNumReorderCalls(requests, 0); }); it('can reorder within a group', function () { var requests = init(this); // Drag the first component in Group A to the end - dragHandle(2, 80); + dragHandleVertically(2, 80); respondToRequest(requests, 0, 200); verifyNumReorderCalls(requests, 1); verifyRequest(requests, 0, groupAUrl, [groupAComponent2, groupAComponent3, groupAComponent1]); @@ -111,7 +119,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", it('can drag from one group to another', function () { var requests = init(this); // Drag the first component in Group A into the second group. - dragHandle(2, 300); + dragHandleVertically(2, 300); 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. @@ -124,7 +132,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", it('does not remove from old group if addition to new group fails', function () { var requests = init(this); // Drag the first component in Group A into the second group. - dragHandle(2, 300); + dragHandleVertically(2, 300); respondToRequest(requests, 0, 500); // Send failure for addition to new group-- no removal event should be received. verifyNumReorderCalls(requests, 1); @@ -135,7 +143,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", it('can swap group A and group B', function () { var requests = init(this); // Drag Group B before group A. - dragHandle(5, -300); + dragHandleVertically(5, -300); respondToRequest(requests, 0, 200); verifyNumReorderCalls(requests, 1); verifyRequest(requests, 0, containerTestUrl, [groupB, groupA]); @@ -145,7 +153,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", 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(2, -40); + dragHandleVertically(2, -40); respondToRequest(requests, 0, 200); respondToRequest(requests, 1, 200); verifyNumReorderCalls(requests, 2); @@ -153,7 +161,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", verifyRequest(requests, 1, groupAUrl, [groupAComponent2, groupAComponent3]); // Drag Group A into Group B. - dragHandle(1, 150); + dragHandleVertically(1, 150); respondToRequest(requests, 2, 200); respondToRequest(requests, 3, 200); verifyNumReorderCalls(requests, 4); @@ -175,7 +183,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", requests = init(this); // Drag the first component in Group A into the second group. - dragHandle(2, 200); + dragHandleVertically(2, 200); expect(savingSpies.constructor).toHaveBeenCalled(); expect(savingSpies.show).toHaveBeenCalled(); @@ -194,7 +202,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", var requests = init(this); // Drag the first component in Group A into the second group. - dragHandle(2, 200); + dragHandleVertically(2, 200); expect(savingSpies.constructor).toHaveBeenCalled(); expect(savingSpies.show).toHaveBeenCalled(); diff --git a/cms/templates/component.html b/cms/templates/component.html index 88e3227892..6b22971200 100644 --- a/cms/templates/component.html +++ b/cms/templates/component.html @@ -26,6 +26,7 @@ - +% if not xblock_context['read_only']: + +% endif ${preview} - diff --git a/cms/templates/container_xblock_component.html b/cms/templates/container_xblock_component.html index cd91f8b50f..da2b98c820 100644 --- a/cms/templates/container_xblock_component.html +++ b/cms/templates/container_xblock_component.html @@ -21,8 +21,7 @@ from contentstore.views.helpers import xblock_studio_url - ## We currently support reordering only on the unit page. - % if reordering_enabled: + % if not xblock_context['read_only']: % endif diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py index c8ce76e1ec..c50cd31c4a 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py @@ -154,7 +154,7 @@ class DraftModuleStore(MongoModuleStore): self.refresh_cached_metadata_inheritance_tree(draft_location) self.fire_updated_modulestore_signal(get_course_id_no_run(draft_location), draft_location) - return self._load_items([original])[0] + return wrap_draft(self._load_items([original])[0]) def update_item(self, xblock, user=None, allow_not_found=False): """ diff --git a/common/lib/xmodule/xmodule/vertical_module.py b/common/lib/xmodule/xmodule/vertical_module.py index f18153c066..d8e8e57c07 100644 --- a/common/lib/xmodule/xmodule/vertical_module.py +++ b/common/lib/xmodule/xmodule/vertical_module.py @@ -46,7 +46,8 @@ class VerticalModule(VerticalFields, XModule): }) fragment.add_content(self.system.render_template(template_name, { - 'items': contents + 'items': contents, + 'xblock_context': context, })) return fragment diff --git a/common/test/acceptance/tests/test_studio_container.py b/common/test/acceptance/tests/test_studio_container.py new file mode 100644 index 0000000000..9f1a9d17a8 --- /dev/null +++ b/common/test/acceptance/tests/test_studio_container.py @@ -0,0 +1,98 @@ +""" +Acceptance tests for Studio related to the container page. +""" +from ..pages.studio.auto_auth import AutoAuthPage +from ..pages.studio.overview import CourseOutlinePage +from ..fixtures.course import CourseFixture, XBlockFixtureDesc + +from .helpers import UniqueCourseTest + + +class ContainerBase(UniqueCourseTest): + """ + Base class for tests that do operations on the container page. + """ + __test__ = False + + def setUp(self): + """ + Create a unique identifier for the course used in this test. + """ + # Ensure that the superclass sets up + super(ContainerBase, self).setUp() + + self.auth_page = AutoAuthPage(self.browser, staff=True) + self.outline = CourseOutlinePage( + self.browser, + self.course_info['org'], + self.course_info['number'], + self.course_info['run'] + ) + + self.setup_fixtures() + + self.auth_page.visit() + + def setup_fixtures(self): + course_fix = CourseFixture( + self.course_info['org'], + self.course_info['number'], + self.course_info['run'], + self.course_info['display_name'] + ) + + course_fix.add_children( + XBlockFixtureDesc('chapter', 'Test Section').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection').add_children( + XBlockFixtureDesc('vertical', 'Test Unit').add_children( + XBlockFixtureDesc('vertical', 'Test Container').add_children( + XBlockFixtureDesc('vertical', 'Group A').add_children( + XBlockFixtureDesc('html', 'Group A Item 1'), + XBlockFixtureDesc('html', 'Group A Item 2') + ), + XBlockFixtureDesc('vertical', 'Group B').add_children( + XBlockFixtureDesc('html', 'Group B Item 1'), + XBlockFixtureDesc('html', 'Group B Item 2') + ) + ) + ) + ) + ) + ).install() + + def go_to_container_page(self, make_draft=False): + self.outline.visit() + subsection = self.outline.section('Test Section').subsection('Test Subsection') + unit = subsection.toggle_expand().unit('Test Unit').go_to() + if make_draft: + unit.edit_draft() + container = unit.components[0].go_to_container() + return container + + +class DragAndDropTest(ContainerBase): + """ + Tests of reordering within the container page. + """ + __test__ = True + + def verify_ordering(self, container, expected_ordering): + xblocks = container.xblocks + for xblock in xblocks: + print xblock.name + # TODO: need to verify parenting structure on page. Just checking + # the order of the xblocks is not sufficient. + + + def test_reorder_in_group(self): + container = self.go_to_container_page(make_draft=True) + # Swap Group A Item 1 and Group A Item 2. + container.drag(1, 2) + + expected_ordering = [{"Group A": ["Group A Item 2", "Group A Item 1"]}, + {"Group B": ["Group B Item 1", "Group B Item 2"]}] + self.verify_ordering(container, expected_ordering) + + # Reload the page to see that the reordering was saved persisted. + container = self.go_to_container_page() + self.verify_ordering(container, expected_ordering) diff --git a/lms/templates/vert_module_studio_view.html b/lms/templates/vert_module_studio_view.html index b8bab17950..d12a1ca896 100644 --- a/lms/templates/vert_module_studio_view.html +++ b/lms/templates/vert_module_studio_view.html @@ -6,7 +6,9 @@ from django.utils.translation import ugettext as _ % for idx, item in enumerate(items):
  • + % if not xblock_context['read_only']: + % endif ${item['content']}
  • From ff00fbd1f1ff7acfdf30d700e482f47c9f873e15 Mon Sep 17 00:00:00 2001 From: cahrens Date: Wed, 9 Apr 2014 16:50:34 -0400 Subject: [PATCH 07/12] Bok choy acceptance test for drag and drop. --- .../test/acceptance/pages/studio/container.py | 36 +++++ ...t_studio.py => test_studio_acid_xblock.py} | 143 +---------------- .../acceptance/tests/test_studio_container.py | 94 +++++++++-- .../acceptance/tests/test_studio_general.py | 148 ++++++++++++++++++ 4 files changed, 264 insertions(+), 157 deletions(-) rename common/test/acceptance/tests/{test_studio.py => test_studio_acid_xblock.py} (59%) create mode 100644 common/test/acceptance/tests/test_studio_general.py diff --git a/common/test/acceptance/pages/studio/container.py b/common/test/acceptance/pages/studio/container.py index 070f39a7ea..2f3b8a09b5 100644 --- a/common/test/acceptance/pages/studio/container.py +++ b/common/test/acceptance/pages/studio/container.py @@ -6,6 +6,7 @@ from bok_choy.page_object import PageObject from bok_choy.promise import Promise from . import BASE_URL +from selenium.webdriver.common.action_chains import ActionChains class ContainerPage(PageObject): """ @@ -44,6 +45,25 @@ class ContainerPage(PageObject): return self.q(css=XBlockWrapper.BODY_SELECTOR).map( lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results + def drag(self, source_index, target_index, after=True): + """ + Gets the drag handle with index source_index (relative to the vertical layout of the page) + and drags it to the location of the drag handle with target_index. + + This should drag the element with the source_index drag handle AFTER the + one with the target_index drag handle, unless 'after' is set to False. + """ + draggables = self.q(css='.drag-handle') + source = draggables[source_index] + target = draggables[target_index] + action = ActionChains(self.browser) + action.click_and_hold(source).perform() # pylint: disable=protected-access + action.move_to_element_with_offset( + target, 0, target.size['height']/2 if after else 0 + ).perform() # pylint: disable=protected-access + action.release().perform() + # TODO: should wait for "Saving" to go away so we know the operation is complete? + class XBlockWrapper(PageObject): """ @@ -78,6 +98,22 @@ class XBlockWrapper(PageObject): else: return None + @property + def children(self): + """ + Will return any first-generation descendant xblocks of this xblock. + """ + descendants = self.q(css=self._bounded_selector(self.BODY_SELECTOR)).map( + lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results + + # Now remove any non-direct descendants. + grandkids = [] + for descendant in descendants: + grandkids.extend(descendant.children) + + grand_locators = [grandkid.locator for grandkid in grandkids] + return [descendant for descendant in descendants if not descendant.locator in grand_locators] + @property def preview_selector(self): return self._bounded_selector('.xblock-student_view') diff --git a/common/test/acceptance/tests/test_studio.py b/common/test/acceptance/tests/test_studio_acid_xblock.py similarity index 59% rename from common/test/acceptance/tests/test_studio.py rename to common/test/acceptance/tests/test_studio_acid_xblock.py index 158ff929c2..0e1e2d7231 100644 --- a/common/test/acceptance/tests/test_studio.py +++ b/common/test/acceptance/tests/test_studio_acid_xblock.py @@ -1,156 +1,15 @@ """ -Acceptance tests for Studio. +Acceptance tests for Studio related to the acid xblock. """ from unittest import skip from bok_choy.web_app_test import WebAppTest -from ..pages.studio.asset_index import AssetIndexPage from ..pages.studio.auto_auth import AutoAuthPage -from ..pages.studio.checklists import ChecklistsPage -from ..pages.studio.course_import import ImportPage -from ..pages.studio.course_info import CourseUpdatesPage -from ..pages.studio.edit_tabs import PagesPage -from ..pages.studio.export import ExportPage -from ..pages.studio.howitworks import HowitworksPage -from ..pages.studio.index import DashboardPage -from ..pages.studio.login import LoginPage -from ..pages.studio.manage_users import CourseTeamPage from ..pages.studio.overview import CourseOutlinePage -from ..pages.studio.settings import SettingsPage -from ..pages.studio.settings_advanced import AdvancedSettingsPage -from ..pages.studio.settings_graders import GradingPage -from ..pages.studio.signup import SignupPage -from ..pages.studio.textbooks import TextbooksPage from ..pages.xblock.acid import AcidView from ..fixtures.course import CourseFixture, XBlockFixtureDesc -from .helpers import UniqueCourseTest - - -class LoggedOutTest(WebAppTest): - """ - Smoke test for pages in Studio that are visible when logged out. - """ - - def setUp(self): - super(LoggedOutTest, self).setUp() - self.pages = [LoginPage(self.browser), HowitworksPage(self.browser), SignupPage(self.browser)] - - def test_page_existence(self): - """ - Make sure that all the pages are accessible. - Rather than fire up the browser just to check each url, - do them all sequentially in this testcase. - """ - for page in self.pages: - page.visit() - - -class LoggedInPagesTest(WebAppTest): - """ - Tests that verify the pages in Studio that you can get to when logged - in and do not have a course yet. - """ - - def setUp(self): - super(LoggedInPagesTest, self).setUp() - self.auth_page = AutoAuthPage(self.browser, staff=True) - self.dashboard_page = DashboardPage(self.browser) - - def test_dashboard_no_courses(self): - """ - Make sure that you can get to the dashboard page without a course. - """ - self.auth_page.visit() - self.dashboard_page.visit() - - -class CoursePagesTest(UniqueCourseTest): - """ - Tests that verify the pages in Studio that you can get to when logged - in and have a course. - """ - - COURSE_ID_SEPARATOR = "." - - def setUp(self): - """ - Install a course with no content using a fixture. - """ - super(UniqueCourseTest, self).setUp() - - CourseFixture( - self.course_info['org'], - self.course_info['number'], - self.course_info['run'], - self.course_info['display_name'] - ).install() - - self.auth_page = AutoAuthPage(self.browser, staff=True) - - self.pages = [ - clz(self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run']) - for clz in [ - AssetIndexPage, ChecklistsPage, ImportPage, CourseUpdatesPage, - PagesPage, ExportPage, CourseTeamPage, CourseOutlinePage, SettingsPage, - AdvancedSettingsPage, GradingPage, TextbooksPage - ] - ] - - def test_page_existence(self): - """ - Make sure that all these pages are accessible once you have a course. - Rather than fire up the browser just to check each url, - do them all sequentially in this testcase. - """ - # Log in - self.auth_page.visit() - - # Verify that each page is available - for page in self.pages: - page.visit() - - -class DiscussionPreviewTest(UniqueCourseTest): - """ - Tests that Inline Discussions are rendered with a custom preview in Studio - """ - - def setUp(self): - super(DiscussionPreviewTest, self).setUp() - CourseFixture(**self.course_info).add_children( - XBlockFixtureDesc("chapter", "Test Section").add_children( - XBlockFixtureDesc("sequential", "Test Subsection").add_children( - XBlockFixtureDesc("vertical", "Test Unit").add_children( - XBlockFixtureDesc( - "discussion", - "Test Discussion", - ) - ) - ) - ) - ).install() - - AutoAuthPage(self.browser, staff=True).visit() - cop = CourseOutlinePage( - self.browser, - self.course_info['org'], - self.course_info['number'], - self.course_info['run'] - ) - cop.visit() - self.unit = cop.section('Test Section').subsection('Test Subsection').toggle_expand().unit('Test Unit') - self.unit.go_to() - - def test_is_preview(self): - """ - Ensure that the preview version of the discussion is rendered. - """ - self.assertTrue(self.unit.q(css=".discussion-preview").present) - self.assertFalse(self.unit.q(css=".discussion-show").present) - - class XBlockAcidBase(WebAppTest): """ Base class for tests that verify that XBlock integration is working correctly diff --git a/common/test/acceptance/tests/test_studio_container.py b/common/test/acceptance/tests/test_studio_container.py index 9f1a9d17a8..80d51635d4 100644 --- a/common/test/acceptance/tests/test_studio_container.py +++ b/common/test/acceptance/tests/test_studio_container.py @@ -29,6 +29,15 @@ class ContainerBase(UniqueCourseTest): self.course_info['run'] ) + self.container_title = "" + self.group_a = "Expand or Collapse\nGroup A" + self.group_b = "Expand or Collapse\nGroup B" + self.group_empty = "Expand or Collapse\nGroup Empty" + self.group_a_item_1 = "Group A Item 1" + self.group_a_item_2 = "Group A Item 2" + self.group_b_item_1 = "Group B Item 1" + self.group_b_item_2 = "Group B Item 2" + self.setup_fixtures() self.auth_page.visit() @@ -47,12 +56,13 @@ class ContainerBase(UniqueCourseTest): XBlockFixtureDesc('vertical', 'Test Unit').add_children( XBlockFixtureDesc('vertical', 'Test Container').add_children( XBlockFixtureDesc('vertical', 'Group A').add_children( - XBlockFixtureDesc('html', 'Group A Item 1'), - XBlockFixtureDesc('html', 'Group A Item 2') + XBlockFixtureDesc('html', self.group_a_item_1), + XBlockFixtureDesc('html', self.group_a_item_2) ), + XBlockFixtureDesc('vertical', 'Group Empty'), XBlockFixtureDesc('vertical', 'Group B').add_children( - XBlockFixtureDesc('html', 'Group B Item 1'), - XBlockFixtureDesc('html', 'Group B Item 2') + XBlockFixtureDesc('html', self.group_b_item_1), + XBlockFixtureDesc('html', self.group_b_item_2) ) ) ) @@ -76,23 +86,77 @@ class DragAndDropTest(ContainerBase): """ __test__ = True - def verify_ordering(self, container, expected_ordering): + def verify_ordering(self, container, expected_orderings): xblocks = container.xblocks - for xblock in xblocks: - print xblock.name - # TODO: need to verify parenting structure on page. Just checking - # the order of the xblocks is not sufficient. + for expected_ordering in expected_orderings: + for xblock in xblocks: + parent = expected_ordering.keys()[0] + if xblock.name == parent: + children = xblock.children + expected_length = len(expected_ordering.get(parent)) + self.assertEqual( + expected_length, len(children), + "Number of children incorrect for group {0}. Expected {1} but got {2}.".format(parent, expected_length, len(children))) + for idx, expected in enumerate(expected_ordering.get(parent)): + self.assertEqual(expected, children[idx].name) - - def test_reorder_in_group(self): + def drag_and_verify(self, source, target, expected_ordering, after=True): container = self.go_to_container_page(make_draft=True) - # Swap Group A Item 1 and Group A Item 2. - container.drag(1, 2) + container.drag(source, target, after) - expected_ordering = [{"Group A": ["Group A Item 2", "Group A Item 1"]}, - {"Group B": ["Group B Item 1", "Group B Item 2"]}] self.verify_ordering(container, expected_ordering) # Reload the page to see that the reordering was saved persisted. container = self.go_to_container_page() self.verify_ordering(container, expected_ordering) + + def test_reorder_in_group(self): + """ + Drag Group B Item 2 before Group B Item 1. + """ + expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]}, + {self.group_a: [self.group_a_item_1, self.group_a_item_2]}, + {self.group_b: [self.group_b_item_2, self.group_b_item_1]}, + {self.group_empty: []}] + self.drag_and_verify(6, 4, expected_ordering) + + def test_drag_to_top(self): + """ + Drag Group A Item 1 to top level (outside of Group A). + """ + expected_ordering = [{self.container_title: [self.group_a_item_1, self.group_a, self.group_empty, self.group_b]}, + {self.group_a: [self.group_a_item_2]}, + {self.group_b: [self.group_b_item_1, self.group_b_item_2]}, + {self.group_empty: []}] + self.drag_and_verify(1, 0, expected_ordering, False) + + def test_drag_into_different_group(self): + """ + Drag Group A Item 1 into Group B (last element). + """ + expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]}, + {self.group_a: [self.group_a_item_2]}, + {self.group_b: [self.group_b_item_1, self.group_b_item_2, self.group_a_item_1]}, + {self.group_empty: []}] + self.drag_and_verify(1, 6, expected_ordering) + + def test_drag_group_into_group(self): + """ + Drag Group B into Group A (last element). + """ + expected_ordering = [{self.container_title: [self.group_a, self.group_empty]}, + {self.group_a: [self.group_a_item_1, self.group_a_item_2, self.group_b]}, + {self.group_b: [self.group_b_item_1, self.group_b_item_2]}, + {self.group_empty: []}] + self.drag_and_verify(4, 2, expected_ordering) + + # Not able to drag into the empty group with automation (difficult even outside of automation). + # def test_drag_into_empty(self): + # """ + # Drag Group B Item 1 to Group Empty. + # """ + # expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]}, + # {self.group_a: [self.group_a_item_1, self.group_a_item_2]}, + # {self.group_b: [self.group_b_item_2]}, + # {self.group_empty: [self.group_b_item_1]}] + # self.drag_and_verify(6, 4, expected_ordering, False) diff --git a/common/test/acceptance/tests/test_studio_general.py b/common/test/acceptance/tests/test_studio_general.py new file mode 100644 index 0000000000..4b58d0e57b --- /dev/null +++ b/common/test/acceptance/tests/test_studio_general.py @@ -0,0 +1,148 @@ +""" +Acceptance tests for Studio. +""" +from bok_choy.web_app_test import WebAppTest + +from ..pages.studio.asset_index import AssetIndexPage +from ..pages.studio.auto_auth import AutoAuthPage +from ..pages.studio.checklists import ChecklistsPage +from ..pages.studio.course_import import ImportPage +from ..pages.studio.course_info import CourseUpdatesPage +from ..pages.studio.edit_tabs import PagesPage +from ..pages.studio.export import ExportPage +from ..pages.studio.howitworks import HowitworksPage +from ..pages.studio.index import DashboardPage +from ..pages.studio.login import LoginPage +from ..pages.studio.manage_users import CourseTeamPage +from ..pages.studio.overview import CourseOutlinePage +from ..pages.studio.settings import SettingsPage +from ..pages.studio.settings_advanced import AdvancedSettingsPage +from ..pages.studio.settings_graders import GradingPage +from ..pages.studio.signup import SignupPage +from ..pages.studio.textbooks import TextbooksPage +from ..fixtures.course import CourseFixture, XBlockFixtureDesc + +from .helpers import UniqueCourseTest + + +class LoggedOutTest(WebAppTest): + """ + Smoke test for pages in Studio that are visible when logged out. + """ + + def setUp(self): + super(LoggedOutTest, self).setUp() + self.pages = [LoginPage(self.browser), HowitworksPage(self.browser), SignupPage(self.browser)] + + def test_page_existence(self): + """ + Make sure that all the pages are accessible. + Rather than fire up the browser just to check each url, + do them all sequentially in this testcase. + """ + for page in self.pages: + page.visit() + + +class LoggedInPagesTest(WebAppTest): + """ + Tests that verify the pages in Studio that you can get to when logged + in and do not have a course yet. + """ + + def setUp(self): + super(LoggedInPagesTest, self).setUp() + self.auth_page = AutoAuthPage(self.browser, staff=True) + self.dashboard_page = DashboardPage(self.browser) + + def test_dashboard_no_courses(self): + """ + Make sure that you can get to the dashboard page without a course. + """ + self.auth_page.visit() + self.dashboard_page.visit() + + +class CoursePagesTest(UniqueCourseTest): + """ + Tests that verify the pages in Studio that you can get to when logged + in and have a course. + """ + + COURSE_ID_SEPARATOR = "." + + def setUp(self): + """ + Install a course with no content using a fixture. + """ + super(UniqueCourseTest, self).setUp() + + CourseFixture( + self.course_info['org'], + self.course_info['number'], + self.course_info['run'], + self.course_info['display_name'] + ).install() + + self.auth_page = AutoAuthPage(self.browser, staff=True) + + self.pages = [ + clz(self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run']) + for clz in [ + AssetIndexPage, ChecklistsPage, ImportPage, CourseUpdatesPage, + PagesPage, ExportPage, CourseTeamPage, CourseOutlinePage, SettingsPage, + AdvancedSettingsPage, GradingPage, TextbooksPage + ] + ] + + def test_page_existence(self): + """ + Make sure that all these pages are accessible once you have a course. + Rather than fire up the browser just to check each url, + do them all sequentially in this testcase. + """ + # Log in + self.auth_page.visit() + + # Verify that each page is available + for page in self.pages: + page.visit() + + +class DiscussionPreviewTest(UniqueCourseTest): + """ + Tests that Inline Discussions are rendered with a custom preview in Studio + """ + + def setUp(self): + super(DiscussionPreviewTest, self).setUp() + CourseFixture(**self.course_info).add_children( + XBlockFixtureDesc("chapter", "Test Section").add_children( + XBlockFixtureDesc("sequential", "Test Subsection").add_children( + XBlockFixtureDesc("vertical", "Test Unit").add_children( + XBlockFixtureDesc( + "discussion", + "Test Discussion", + ) + ) + ) + ) + ).install() + + AutoAuthPage(self.browser, staff=True).visit() + cop = CourseOutlinePage( + self.browser, + self.course_info['org'], + self.course_info['number'], + self.course_info['run'] + ) + cop.visit() + self.unit = cop.section('Test Section').subsection('Test Subsection').toggle_expand().unit('Test Unit') + self.unit.go_to() + + def test_is_preview(self): + """ + Ensure that the preview version of the discussion is rendered. + """ + self.assertTrue(self.unit.q(css=".discussion-preview").present) + self.assertFalse(self.unit.q(css=".discussion-show").present) From c6b96f32a24558f3850397affb643424f9e7f561 Mon Sep 17 00:00:00 2001 From: cahrens Date: Fri, 2 May 2014 09:38:37 -0400 Subject: [PATCH 08/12] Allow for no runtime_type in the context. --- common/lib/xmodule/xmodule/vertical_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/vertical_module.py b/common/lib/xmodule/xmodule/vertical_module.py index d8e8e57c07..6dc8ab76d4 100644 --- a/common/lib/xmodule/xmodule/vertical_module.py +++ b/common/lib/xmodule/xmodule/vertical_module.py @@ -18,7 +18,7 @@ class VerticalModule(VerticalFields, XModule): def student_view(self, context): # When rendering a Studio preview, use a different template to support drag and drop. - if context and context['runtime_type'] == 'studio': + if context and context.get('runtime_type', None) == 'studio': return self.studio_preview_view(context) return self.render_view(context, 'vert_module.html') From e20a7fc53cca2b3b3db9a0cc4baa9ffbe550c17a Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Thu, 1 May 2014 23:04:11 -0400 Subject: [PATCH 09/12] Fix Jasmine tests Also, rewrite them to be easier to comprehend, and to not use magic constants (or to use fewer, at least). --- cms/static/js/spec/views/container_spec.js | 128 ++++++++++---------- cms/static/js/spec_helpers/modal_helpers.js | 14 +-- cms/static/js/spec_helpers/view_helpers.js | 20 +++ cms/static/js/views/container.js | 5 - 4 files changed, 91 insertions(+), 76 deletions(-) create mode 100644 cms/static/js/spec_helpers/view_helpers.js diff --git a/cms/static/js/spec/views/container_spec.js b/cms/static/js/spec/views/container_spec.js index 45e76ed74a..170e90d672 100644 --- a/cms/static/js/spec/views/container_spec.js +++ b/cms/static/js/spec/views/container_spec.js @@ -1,15 +1,15 @@ -define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", "js/models/xblock_info", - "js/views/feedback_notification", "jquery.simulate", +define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers", + "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) { + function ($, create_sinon, view_helpers, ContainerView, XBlockInfo, Notification) { describe("Container View", function () { describe("Supports reordering components", function () { - var model, containerView, mockContainerHTML, respondWithMockXBlockFragment, - init, dragHandleVertically, dragHandleOver, verifyRequest, verifyNumReorderCalls, - respondToRequest, + var model, containerView, mockContainerHTML, respondWithMockXBlockFragment, init, getComponent, + getDragHandle, dragComponentVertically, dragComponentToY, dragComponentAbove, dragComponentBelow, + verifyRequest, verifyNumReorderCalls, respondToRequest, rootLocator = 'testCourse/branch/draft/split_test/splitFFF', containerTestUrl = '/xblock/' + rootLocator, @@ -35,7 +35,8 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", }; beforeEach(function () { - setFixtures('
    '); + view_helpers.installViewTemplates(); + appendSetFixtures('
    '); model = new XBlockInfo({ id: rootLocator, display_name: 'Test AB Test', @@ -66,22 +67,43 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", return requests; }; - dragHandleVertically = function (index, dy) { - var handle = containerView.$(".drag-handle:eq(" + index + ")"); + getComponent = function(locator) { + return containerView.$('[data-locator="' + locator + '"]'); + }; + + getDragHandle = function(locator) { + var component = getComponent(locator); + return component.prev(); + }; + + dragComponentVertically = function (locator, dy) { + var handle = getDragHandle(locator); handle.simulate("drag", {dy: dy}); }; - dragHandleOver = function (index, targetElement) { - var handle = containerView.$(".drag-handle:eq(" + index + ")"), - dy = handle.y - targetElement.y; - + dragComponentToY = function (locator, y) { + var handle = getDragHandle(locator), + handleY = handle.offset().top + (handle.height() / 2), + dy = y - handleY; handle.simulate("drag", {dy: dy}); }; + dragComponentAbove = function (sourceLocator, targetLocator) { + var targetElement = getComponent(targetLocator); + dragComponentToY(sourceLocator, targetElement.offset().top + 1); + }; + + dragComponentBelow = function (sourceLocator, targetLocator) { + var targetElement = containerView.$('[data-locator="' + targetLocator + '"]'); + dragComponentToY(sourceLocator, targetElement.offset().top + targetElement.height() - 1); + }; + verifyRequest = function (requests, reorderCallIndex, expectedURL, expectedChildren) { - var request, children, i; + var actualIndex, request, children, i; // 0th call is the response to the initial render call to get HTML. - request = requests[reorderCallIndex + 1]; + actualIndex = reorderCallIndex + 1; + expect(requests.length).toBeGreaterThan(actualIndex); + request = requests[actualIndex]; expect(request.url).toEqual(expectedURL); children = (JSON.parse(request.requestBody)).children; expect(children.length).toEqual(expectedChildren.length); @@ -96,79 +118,62 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", }; respondToRequest = function (requests, reorderCallIndex, status) { + var actualIndex; // Number of calls will be 1 more than expected because of the initial render call to get HTML. - requests[reorderCallIndex + 1].respond(status); + actualIndex = reorderCallIndex + 1; + expect(requests.length).toBeGreaterThan(actualIndex); + requests[actualIndex].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. - dragHandleVertically(2, 5); + // Drag the first component in Group A down very slightly but not enough to move it. + dragComponentVertically(groupAComponent1, 5); verifyNumReorderCalls(requests, 0); }); it('can reorder within a group', function () { var requests = init(this); - // Drag the first component in Group A to the end - dragHandleVertically(2, 80); + // Drag the third component in Group A to be the first + dragComponentAbove(groupAComponent3, groupAComponent1); respondToRequest(requests, 0, 200); - verifyNumReorderCalls(requests, 1); - verifyRequest(requests, 0, groupAUrl, [groupAComponent2, groupAComponent3, groupAComponent1]); + verifyRequest(requests, 0, groupAUrl, [groupAComponent3, groupAComponent1, groupAComponent2]); }); it('can drag from one group to another', function () { var requests = init(this); - // Drag the first component in Group A into the second group. - dragHandleVertically(2, 300); + // Drag the first component in Group B to the first group. + dragComponentAbove(groupBComponent1, groupAComponent1); + + // Respond to the first request which will trigger a request to make the move 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, - [groupBComponent1, groupBComponent2, groupAComponent1, groupBComponent3]); - verifyRequest(requests, 1, groupAUrl, [groupAComponent2, groupAComponent3]); + + verifyRequest(requests, 0, groupAUrl, + [groupBComponent1, groupAComponent1, groupAComponent2, groupAComponent3]); + verifyRequest(requests, 1, groupBUrl, [groupBComponent2, groupBComponent3]); }); it('does not remove from old group if addition to new group fails', function () { var requests = init(this); - // Drag the first component in Group A into the second group. - dragHandleVertically(2, 300); + // Drag the first component in Group B to the first group. + dragComponentAbove(groupBComponent1, groupAComponent1); respondToRequest(requests, 0, 500); - // Send failure for addition to new group-- no removal event should be received. + // Send failure for addition to new group -- no removal event should be received. + verifyRequest(requests, 0, groupAUrl, + [groupBComponent1, groupAComponent1, groupAComponent2, groupAComponent3]); + // Verify that a second request was not issued verifyNumReorderCalls(requests, 1); - verifyRequest(requests, 0, groupBUrl, - [groupBComponent1, groupBComponent2, groupAComponent1, groupBComponent3]); }); it('can swap group A and group B', function () { var requests = init(this); // Drag Group B before group A. - dragHandleVertically(5, -300); + dragComponentAbove(groupB, groupA); respondToRequest(requests, 0, 200); - verifyNumReorderCalls(requests, 1); verifyRequest(requests, 0, containerTestUrl, [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). - dragHandleVertically(2, -40); - respondToRequest(requests, 0, 200); - respondToRequest(requests, 1, 200); - verifyNumReorderCalls(requests, 2); - verifyRequest(requests, 0, containerTestUrl, [groupAComponent1, groupA, groupB]); - verifyRequest(requests, 1, groupAUrl, [groupAComponent2, groupAComponent3]); - - // Drag Group A into Group B. - dragHandleVertically(1, 150); - respondToRequest(requests, 2, 200); - respondToRequest(requests, 3, 200); - verifyNumReorderCalls(requests, 4); - verifyRequest(requests, 2, groupBUrl, [groupBComponent1, groupA, groupBComponent2]); - verifyRequest(requests, 3, containerTestUrl, [groupAComponent1, groupB]); - }); - describe("Shows a saving message", function () { var savingSpies; @@ -182,8 +187,8 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", var requests, savingOptions; requests = init(this); - // Drag the first component in Group A into the second group. - dragHandleVertically(2, 200); + // Drag the first component in Group B to the first group. + dragComponentAbove(groupBComponent1, groupAComponent1); expect(savingSpies.constructor).toHaveBeenCalled(); expect(savingSpies.show).toHaveBeenCalled(); @@ -195,14 +200,13 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", 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 the first component in Group A into the second group. - dragHandleVertically(2, 200); + // Drag the first component in Group B to the first group. + dragComponentAbove(groupBComponent1, groupAComponent1); expect(savingSpies.constructor).toHaveBeenCalled(); expect(savingSpies.show).toHaveBeenCalled(); @@ -210,6 +214,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", respondToRequest(requests, 0, 500); expect(savingSpies.hide).not.toHaveBeenCalled(); + // Since the first reorder call failed, the removal will not be called. verifyNumReorderCalls(requests, 1); }); @@ -217,4 +222,3 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", "js/views/container", }); }); }); -147 \ No newline at end of file diff --git a/cms/static/js/spec_helpers/modal_helpers.js b/cms/static/js/spec_helpers/modal_helpers.js index 8556cce60a..97cd94e838 100644 --- a/cms/static/js/spec_helpers/modal_helpers.js +++ b/cms/static/js/spec_helpers/modal_helpers.js @@ -1,8 +1,8 @@ /** * Provides helper methods for invoking Studio modal windows in Jasmine tests. */ -define(["jquery"], - function($) { +define(["jquery", "js/spec_helpers/view_helpers"], + function($, view_helpers) { var basicModalTemplate = readFixtures('basic-modal.underscore'), modalButtonTemplate = readFixtures('modal-button.underscore'), feedbackTemplate = readFixtures('system-feedback.underscore'), @@ -14,11 +14,7 @@ define(["jquery"], cancelModalIfShowing; installModalTemplates = function(append) { - if (append) { - appendSetFixtures($("