diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 40cf14c1f2..97990e9f41 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -5,8 +5,14 @@ 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.
+Blades: Transcript translations should be displayed in their source language (BLD-935).
+
Blades: Create an upload modal for video transcript translations (BLD-751).
+Studio and LMS: Upgrade version of TinyMCE to 4.0.20. Switch from tabbed Visual/HTML
+Editor for HTML modules to showing the code editor as a plugin within TinyMCE (triggered
+from toolbar). STUD-1422
+
Studio: Add ability to reorder Pages and hide the Wiki page. STUD-1375
Blades: Added template for iFrames. BLD-611.
diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py
index 8353a58d0c..7afa359e39 100644
--- a/cms/djangoapps/contentstore/features/common.py
+++ b/cms/djangoapps/contentstore/features/common.py
@@ -318,19 +318,23 @@ def i_am_shown_a_notification(step):
assert world.is_css_present('.wrapper-prompt')
-def type_in_codemirror(index, text):
+def type_in_codemirror(index, text, find_prefix="$"):
script = """
- var cm = $('div.CodeMirror:eq({})').get(0).CodeMirror;
+ var cm = {find_prefix}('div.CodeMirror:eq({index})').get(0).CodeMirror;
cm.getInputField().focus();
cm.setValue(arguments[0]);
- cm.getInputField().blur();""".format(index)
+ cm.getInputField().blur();""".format(index=index, find_prefix=find_prefix)
world.browser.driver.execute_script(script, str(text))
world.wait_for_ajax_complete()
-def get_codemirror_value(index=0):
- return world.browser.driver.execute_script("""
- return $('div.CodeMirror:eq({})').get(0).CodeMirror.getValue();
- """.format(index))
+
+def get_codemirror_value(index=0, find_prefix="$"):
+ return world.browser.driver.execute_script(
+ """
+ return {find_prefix}('div.CodeMirror:eq({index})').get(0).CodeMirror.getValue();
+ """.format(index=index, find_prefix=find_prefix)
+ )
+
def attach_file(filename, sub_path):
diff --git a/cms/djangoapps/contentstore/features/html-editor.feature b/cms/djangoapps/contentstore/features/html-editor.feature
index 6472f367b7..a23bc4650d 100644
--- a/cms/djangoapps/contentstore/features/html-editor.feature
+++ b/cms/djangoapps/contentstore/features/html-editor.feature
@@ -22,6 +22,50 @@ Feature: CMS.HTML Editor
Scenario: TinyMCE image plugin sets urls correctly
Given I have created a Blank HTML Page
- When I edit the page and select the Visual Editor
- And I add an image with a static link via the Image Plugin Icon
- Then the image static link is rewritten to translate the path
\ No newline at end of file
+ When I edit the page
+ And I add an image with static link "/static/image.jpg" via the Image Plugin Icon
+ Then the src link is rewritten to "c4x/MITx/999/asset/image.jpg"
+ And the link is shown as "/static/image.jpg" in the Image Plugin
+
+ Scenario: TinyMCE link plugin sets urls correctly
+ Given I have created a Blank HTML Page
+ When I edit the page
+ And I add a link with static link "/static/image.jpg" via the Link Plugin Icon
+ Then the href link is rewritten to "c4x/MITx/999/asset/image.jpg"
+ And the link is shown as "/static/image.jpg" in the Link Plugin
+
+ Scenario: TinyMCE and CodeMirror preserve style tags
+ Given I have created a Blank HTML Page
+ When I edit the page
+ And type "
pages
" in the code editor and press OK
+ And I save the page
+ Then the page text contains:
+ """
+ pages
+
+ """
+
+ Scenario: TinyMCE toolbar buttons are as expected
+ Given I have created a Blank HTML Page
+ When I edit the page
+ Then the expected toolbar buttons are displayed
+
+ Scenario: Static links are converted when switching between code editor and WYSIWYG views
+ Given I have created a Blank HTML Page
+ When I edit the page
+ And type "
" in the code editor and press OK
+ Then the src link is rewritten to "c4x/MITx/999/asset/image.jpg"
+ And the code editor displays "
"
+
+ Scenario: Code format toolbar button wraps text with code tags
+ Given I have created a Blank HTML Page
+ When I edit the page
+ And I set the text to "display as code" and I select the text
+ And I select the code toolbar button
+ And I save the page
+ Then the page text contains:
+ """
+ display as code
+ """
diff --git a/cms/djangoapps/contentstore/features/html-editor.py b/cms/djangoapps/contentstore/features/html-editor.py
index 89e4100367..4a39eb299f 100644
--- a/cms/djangoapps/contentstore/features/html-editor.py
+++ b/cms/djangoapps/contentstore/features/html-editor.py
@@ -2,7 +2,10 @@
# pylint: disable=C0111
from lettuce import world, step
-from nose.tools import assert_in # pylint: disable=no-name-in-module
+from nose.tools import assert_in, assert_equal # pylint: disable=no-name-in-module
+from common import type_in_codemirror, get_codemirror_value
+
+CODEMIRROR_SELECTOR_PREFIX = "$('iframe').contents().find"
@step('I have created a Blank HTML Page$')
@@ -31,41 +34,168 @@ def i_created_etext_in_latex(step):
)
-@step('I edit the page and select the Visual Editor')
+@step('I edit the page$')
def i_click_on_edit_icon(step):
world.edit_component()
- world.wait_for(lambda _driver: world.css_visible('a.visual-tab'))
- world.css_click('a.visual-tab')
-@step('I add an image with a static link via the Image Plugin Icon')
-def i_click_on_image_plugin_icon(step):
- # Click on image plugin button
- world.wait_for(lambda _driver: world.css_visible('a.mce_image'))
- world.css_click('a.mce_image')
-
- # Change to the non-modal TinyMCE Image window
- # keeping parent window so we can go back to it.
- parent_window = world.browser.current_window
- for window in world.browser.windows:
-
- world.browser.switch_to_window(window) # Switch to a different window
- if world.browser.title == 'Insert/Edit Image':
-
- # This is the Image window so find the url text box,
- # enter text in it then hit Insert button.
- url_elem = world.browser.find_by_id("src")
- url_elem.fill('/static/image.jpg')
- world.browser.find_by_id('insert').click()
-
- world.browser.switch_to_window(parent_window) # Switch back to the main window
+@step('I add an image with static link "(.*)" via the Image Plugin Icon$')
+def i_click_on_image_plugin_icon(step, path):
+ use_plugin(
+ '.mce-i-image',
+ lambda: world.css_fill('.mce-textbox', path, 0)
+ )
-@step('the image static link is rewritten to translate the path')
-def image_static_link_is_rewritten(step):
+@step('the link is shown as "(.*)" in the Image Plugin$')
+def check_link_in_image_plugin(step, path):
+ use_plugin(
+ '.mce-i-image',
+ lambda: assert_equal(path, world.css_find('.mce-textbox')[0].value)
+ )
+
+
+@step('I add a link with static link "(.*)" via the Link Plugin Icon$')
+def i_click_on_link_plugin_icon(step, path):
+ def fill_in_link_fields():
+ world.css_fill('.mce-textbox', path, 0)
+ world.css_fill('.mce-textbox', 'picture', 1)
+
+ use_plugin('.mce-i-link', fill_in_link_fields)
+
+
+@step('the link is shown as "(.*)" in the Link Plugin$')
+def check_link_in_link_plugin(step, path):
+ # Ensure caret position is within the link just created.
+ script = """
+ var editor = tinyMCE.activeEditor;
+ editor.selection.select(editor.dom.select('a')[0]);"""
+ world.browser.driver.execute_script(script)
+ world.wait_for_ajax_complete()
+
+ use_plugin(
+ '.mce-i-link',
+ lambda: assert_equal(path, world.css_find('.mce-textbox')[0].value)
+ )
+
+
+@step('type "(.*)" in the code editor and press OK$')
+def type_in_codemirror_plugin(step, text):
+ use_code_editor(
+ lambda: type_in_codemirror(0, text, CODEMIRROR_SELECTOR_PREFIX)
+ )
+
+
+@step('and the code editor displays "(.*)"$')
+def verify_code_editor_text(step, text):
+ use_code_editor(
+ lambda: assert_equal(text, get_codemirror_value(0, CODEMIRROR_SELECTOR_PREFIX))
+ )
+
+
+def use_plugin(button_class, action):
+ # Click on plugin button
+ world.css_click(button_class)
+ perform_action_in_plugin(action)
+
+
+def use_code_editor(action):
+ # Click on plugin button
+ buttons = world.css_find('div.mce-widget>button')
+
+ code_editor = [button for button in buttons if button.text == 'HTML']
+ assert_equal(1, len(code_editor))
+ code_editor[0].click()
+
+ perform_action_in_plugin(action)
+
+
+def perform_action_in_plugin(action):
+ # Wait for the plugin window to open.
+ world.wait_for_visible('.mce-window')
+
+ # Trigger the action
+ action()
+
+ # Click OK
+ world.css_click('.mce-primary')
+
+
+@step('I save the page$')
+def i_click_on_save(step):
+ world.save_component(step)
+
+
+@step('the page text contains:')
+def check_page_text(step):
+ assert_in(step.multiline, world.css_find('.xmodule_HtmlModule').html)
+
+
+@step('the src link is rewritten to "(.*)"$')
+def image_static_link_is_rewritten(step, path):
# Find the TinyMCE iframe within the main window
with world.browser.get_iframe('mce_0_ifr') as tinymce:
image = tinymce.find_by_tag('img').first
+ assert_in(path, image['src'])
- # Test onExecCommandHandler set the url to absolute.
- assert_in('c4x/MITx/999/asset/image.jpg', image['src'])
+
+@step('the href link is rewritten to "(.*)"$')
+def link_static_link_is_rewritten(step, path):
+ # Find the TinyMCE iframe within the main window
+ with world.browser.get_iframe('mce_0_ifr') as tinymce:
+ link = tinymce.find_by_tag('a').first
+ assert_in(path, link['href'])
+
+
+@step('the expected toolbar buttons are displayed$')
+def check_toolbar_buttons(step):
+ dropdowns = world.css_find('.mce-listbox')
+ assert_equal(2, len(dropdowns))
+
+ # Format dropdown
+ assert_equal('Paragraph', dropdowns[0].text)
+ # Font dropdown
+ assert_equal('Font Family', dropdowns[1].text)
+
+ buttons = world.css_find('.mce-ico')
+
+ # Note that the code editor icon is not present because we are now showing text instead of an icon.
+ # However, other test points user the code editor, so we have already verified its presence.
+ expected_buttons = [
+ 'bold',
+ 'italic',
+ 'underline',
+ 'forecolor',
+ # This is our custom "code style" button, which uses an image instead of a class.
+ 'none',
+ 'bullist',
+ 'numlist',
+ 'outdent',
+ 'indent',
+ 'blockquote',
+ 'link',
+ 'unlink',
+ 'image'
+ ]
+
+ assert_equal(len(expected_buttons), len(buttons))
+
+ for index, button in enumerate(expected_buttons):
+ class_names = buttons[index]._element.get_attribute('class')
+ assert_equal("mce-ico mce-i-" + button, class_names)
+
+
+@step('I set the text to "(.*)" and I select the text$')
+def set_text_and_select(step, text):
+ script = """
+ var editor = tinyMCE.activeEditor;
+ editor.setContent(arguments[0]);
+ editor.selection.select(editor.dom.select('p')[0]);"""
+ world.browser.driver.execute_script(script, str(text))
+ world.wait_for_ajax_complete()
+
+
+@step('I select the code toolbar button$')
+def select_code_button(step):
+ # This is our custom "code style" button. It uses an image instead of a class.
+ world.css_click(".mce-i-none")
diff --git a/cms/djangoapps/contentstore/features/transcripts.py b/cms/djangoapps/contentstore/features/transcripts.py
index 00f898fbb6..e198ee3f32 100644
--- a/cms/djangoapps/contentstore/features/transcripts.py
+++ b/cms/djangoapps/contentstore/features/transcripts.py
@@ -201,7 +201,8 @@ def upload_file(_step, file_name):
@step('I see "([^"]*)" text in the captions')
def check_text_in_the_captions(_step, text):
- world.wait_for(lambda _: world.css_text('.subtitles'))
+ world.wait_for_present('.video.is-captions-rendered')
+ world.wait_for(lambda _: world.css_text('.subtitles'), timeout=30)
actual_text = world.css_text('.subtitles')
assert (text in actual_text)
diff --git a/cms/djangoapps/contentstore/features/upload.py b/cms/djangoapps/contentstore/features/upload.py
index d5f2033904..6330f571a2 100644
--- a/cms/djangoapps/contentstore/features/upload.py
+++ b/cms/djangoapps/contentstore/features/upload.py
@@ -26,8 +26,7 @@ def go_to_uploads(_step):
@step(u'I upload the( test)? file "([^"]*)"$')
def upload_file(_step, is_test_file, file_name):
- upload_css = 'a.upload-button'
- world.css_click(upload_css)
+ world.click_link('Upload New File')
if not is_test_file:
_write_test_file(file_name, "test file")
diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py
index b0460e8163..a7974f020f 100644
--- a/cms/djangoapps/contentstore/features/video-editor.py
+++ b/cms/djangoapps/contentstore/features/video-editor.py
@@ -10,7 +10,12 @@ from django.conf import settings
from common import upload_file, attach_file
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
-LANGUAGES = {l[0]: l[1] for l in settings.ALL_LANGUAGES}
+
+NATIVE_LANGUAGES = {lang: label for lang, label in settings.LANGUAGES if len(lang) == 2}
+LANGUAGES = {
+ lang: NATIVE_LANGUAGES.get(lang, display)
+ for lang, display in settings.ALL_LANGUAGES
+}
TRANSLATION_BUTTONS = {
'add': '.metadata-video-translations .create-action',
diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature
index bb4672c499..e465dc6b5b 100644
--- a/cms/djangoapps/contentstore/features/video.feature
+++ b/cms/djangoapps/contentstore/features/video.feature
@@ -5,7 +5,7 @@ Feature: CMS Video Component
# 1
Scenario: YouTube stub server proxies YouTube API correctly
Given youtube stub server proxies YouTube API
- Given I have created a Video component
+ And I have created a Video component
Then I can see video button "play"
And I click video button "play"
Then I can see video button "pause"
@@ -13,8 +13,8 @@ Feature: CMS Video Component
# 2
Scenario: YouTube stub server can block YouTube API
Given youtube stub server blocks YouTube API
- Given I have created a Video component
- Given We explicitly wait for YouTube API to not load
+ And I have created a Video component
+ And I wait for "3" seconds
Then I do not see video button "play"
# 3
diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py
index ef48014da6..b8287047af 100644
--- a/cms/djangoapps/contentstore/features/video.py
+++ b/cms/djangoapps/contentstore/features/video.py
@@ -1,6 +1,7 @@
# pylint: disable=C0111
from lettuce import world, step
+from nose.tools import assert_less
from xmodule.modulestore import Location
from contentstore.utils import get_modulestore
from selenium.webdriver.common.keys import Keys
@@ -32,13 +33,11 @@ def configure_youtube_api(_step, action):
raise ValueError('Parameter `action` should be one of "proxies" or "blocks".')
-@step('We explicitly wait for YouTube API to not load$')
-def wait_for_youtube_api_fail(_step):
- world.wait(3)
-
-
@step('I have created a Video component$')
def i_created_a_video_component(_step):
+
+ assert_less(world.youtube.config['youtube_api_response'].status_code, 400, "Real Youtube server is unavailable")
+
world.create_course_with_unit()
world.create_component_instance(
step=_step,
@@ -51,7 +50,8 @@ def i_created_a_video_component(_step):
world.wait_for_present('.is-initialized')
world.wait(DELAY)
world.wait_for_invisible(SELECTORS['spinner'])
-
+ if not world.youtube.config.get('youtube_api_blocked'):
+ world.wait_for_visible(SELECTORS['controls'])
@step('I have created a Video component with subtitles$')
def i_created_a_video_with_subs(_step):
@@ -197,11 +197,15 @@ def find_caption_line_by_data_index(index):
@step('I focus on caption line with data-index "([^"]*)"$')
def focus_on_caption_line(_step, index):
+ world.wait_for_present('.video.is-captions-rendered')
+ world.wait_for(lambda _: world.css_text('.subtitles'), timeout=30)
find_caption_line_by_data_index(int(index.strip()))._element.send_keys(Keys.TAB)
@step('I press "enter" button on caption line with data-index "([^"]*)"$')
def click_on_the_caption(_step, index):
+ world.wait_for_present('.video.is-captions-rendered')
+ world.wait_for(lambda _: world.css_text('.subtitles'), timeout=30)
find_caption_line_by_data_index(int(index.strip()))._element.send_keys(Keys.ENTER)
@@ -214,7 +218,6 @@ def caption_line_has_class(_step, index, className):
@step('I see a range on slider$')
def see_a_range_slider_with_proper_range(_step):
world.wait_for_visible(VIDEO_BUTTONS['pause'])
-
assert world.css_visible(".slider-range")
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index ab2a2d60b4..344c6d5c57 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -55,11 +55,10 @@ xmodule.x_module.descriptor_global_local_resource_url = local_resource_url
def hash_resource(resource):
"""
- Hash a :class:`xblock.fragment.FragmentResource
+ Hash a :class:`xblock.fragment.FragmentResource`.
"""
md5 = hashlib.md5()
- for data in resource:
- md5.update(data)
+ md5.update(repr(resource))
return md5.hexdigest()
diff --git a/cms/envs/aws.py b/cms/envs/aws.py
index b2de92a738..67fc78e644 100644
--- a/cms/envs/aws.py
+++ b/cms/envs/aws.py
@@ -274,3 +274,6 @@ SESSION_INACTIVITY_TIMEOUT_IN_SECONDS = AUTH_TOKENS.get("SESSION_INACTIVITY_TIME
##### X-Frame-Options response header settings #####
X_FRAME_OPTIONS = ENV_TOKENS.get('X_FRAME_OPTIONS', X_FRAME_OPTIONS)
+
+##### ADVANCED_SECURITY_CONFIG #####
+ADVANCED_SECURITY_CONFIG = ENV_TOKENS.get('ADVANCED_SECURITY_CONFIG', {})
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 434f534a27..022e9dd94b 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -96,6 +96,9 @@ FEATURES = {
# Prevent concurrent logins per user
'PREVENT_CONCURRENT_LOGINS': False,
+
+ # Turn off Advanced Security by default
+ 'ADVANCED_SECURITY': False,
}
ENABLE_JASMINE = False
@@ -310,10 +313,24 @@ PIPELINE_CSS = {
'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css',
'css/vendor/jquery.qtip.min.css',
'js/vendor/markitup/skins/simple/style.css',
- 'js/vendor/markitup/sets/wiki/style.css',
+ 'js/vendor/markitup/sets/wiki/style.css'
],
'output_filename': 'css/cms-style-vendor.css',
},
+ 'style-vendor-tinymce-content': {
+ 'source_filenames': [
+ 'css/tinymce-studio-content-fonts.css',
+ 'js/vendor/tinymce/js/tinymce/skins/studio-tmce4/content.min.css',
+ 'css/tinymce-studio-content.css'
+ ],
+ 'output_filename': 'css/cms-style-vendor-tinymce-content.css',
+ },
+ 'style-vendor-tinymce-skin': {
+ 'source_filenames': [
+ 'js/vendor/tinymce/js/tinymce/skins/studio-tmce4/skin.min.css'
+ ],
+ 'output_filename': 'css/cms-style-vendor-tinymce-skin.css',
+ },
'style-app': {
'source_filenames': [
'sass/style-app.css',
@@ -566,6 +583,7 @@ OPTIONAL_APPS = (
'openassessment.xblock'
)
+
for app_name in OPTIONAL_APPS:
# First attempt to only find the module rather than actually importing it,
# to avoid circular references - only try to import if it can't be found
@@ -578,3 +596,7 @@ for app_name in OPTIONAL_APPS:
except ImportError:
continue
INSTALLED_APPS += (app_name,)
+
+### ADVANCED_SECURITY_CONFIG
+# Empty by default
+ADVANCED_SECURITY_CONFIG = {}
diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee
index 1cbc3fc805..11f937a2b4 100644
--- a/cms/static/coffee/spec/main.coffee
+++ b/cms/static/coffee/spec/main.coffee
@@ -25,8 +25,8 @@ requirejs.config({
"backbone": "xmodule_js/common_static/js/vendor/backbone-min",
"backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min",
"backbone.paginator": "xmodule_js/common_static/js/vendor/backbone.paginator.min",
- "tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/tiny_mce",
- "jquery.tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/jquery.tinymce",
+ "tinymce": "xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min",
+ "jquery.tinymce": "xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce",
"xmodule": "xmodule_js/src/xmodule",
"xblock/cms.runtime.v1": "coffee/src/xblock/cms.runtime.v1",
"xblock": "xmodule_js/common_static/coffee/src/xblock",
@@ -207,16 +207,17 @@ define([
"js/spec/video/transcripts/videolist_spec", "js/spec/video/transcripts/message_manager_spec",
"js/spec/video/transcripts/file_uploader_spec",
- "js/spec/models/explicit_url_spec"
+ "js/spec/models/explicit_url_spec",
+ "js/spec/utils/drag_and_drop_spec",
"js/spec/utils/handle_iframe_binding_spec",
"js/spec/utils/module_spec",
"js/spec/views/baseview_spec",
"js/spec/views/paging_spec",
- "js/spec/views/unit_spec"
- "js/spec/views/xblock_spec"
+ "js/spec/views/unit_spec",
+ "js/spec/views/xblock_spec",
# these tests are run separate in the cms-squire suite, due to process
# isolation issues with Squire.js
diff --git a/cms/static/coffee/spec/main_squire.coffee b/cms/static/coffee/spec/main_squire.coffee
index c3ce440bca..e7e6bef00b 100644
--- a/cms/static/coffee/spec/main_squire.coffee
+++ b/cms/static/coffee/spec/main_squire.coffee
@@ -24,8 +24,8 @@ requirejs.config({
"backbone": "xmodule_js/common_static/js/vendor/backbone-min",
"backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min",
"backbone.paginator": "xmodule_js/common_static/js/vendor/backbone.paginator.min",
- "tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/tiny_mce",
- "jquery.tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/jquery.tinymce",
+ "tinymce": "xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min",
+ "jquery.tinymce": "xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce",
"xmodule": "xmodule_js/src/xmodule",
"xblock/cms.runtime.v1": "coffee/src/xblock/cms.runtime.v1",
"xblock": "xmodule_js/common_static/coffee/src/xblock",
diff --git a/cms/static/coffee/spec/views/overview_spec.coffee b/cms/static/coffee/spec/views/overview_spec.coffee
index c29d31f414..d89c64c145 100644
--- a/cms/static/coffee/spec/views/overview_spec.coffee
+++ b/cms/static/coffee/spec/views/overview_spec.coffee
@@ -54,38 +54,6 @@ define ["js/views/overview", "js/views/feedback_notification", "js/spec/create_s
"""
- appendSetFixtures """
-
-
- -
-
-
-
-
- -
-
-
-
-
-
-
- -
-
-
-
-
- -
-
-
- -
-
-
-
-
-
-
- """
-
spyOn(Overview, 'saveSetSectionScheduleDate').andCallThrough()
# Have to do this here, as it normally gets bound in document.ready()
$('a.action-save').click(Overview.saveSetSectionScheduleDate)
@@ -96,20 +64,6 @@ define ["js/views/overview", "js/views/feedback_notification", "js/spec/create_s
window.analytics = jasmine.createSpyObj('analytics', ['track'])
window.course_location_analytics = jasmine.createSpy()
- Overview.overviewDragger.makeDraggable(
- '.unit',
- '.unit-drag-handle',
- 'ol.sortable-unit-list',
- 'li.courseware-subsection, article.subsection-body'
- )
-
- Overview.overviewDragger.makeDraggable(
- '.courseware-subsection',
- '.subsection-drag-handle',
- '.sortable-subsection-list',
- 'section'
- )
-
afterEach ->
delete window.analytics
delete window.course_location_analytics
@@ -143,305 +97,3 @@ define ["js/views/overview", "js/views/feedback_notification", "js/spec/create_s
# $('a.delete-section-button').click()
# $('a.action-primary').click()
# expect(@notificationSpy).toHaveBeenCalled()
-
- describe "findDestination", ->
- it "correctly finds the drop target of a drag", ->
- $ele = $('#unit-1')
- $ele.offset(
- top: $ele.offset().top + 10, left: $ele.offset().left
- )
- destination = Overview.overviewDragger.findDestination($ele, 1)
- expect(destination.ele).toBe($('#unit-2'))
- expect(destination.attachMethod).toBe('before')
-
- it "can drag and drop across section boundaries, with special handling for single sibling", ->
- $ele = $('#unit-1')
- $unit4 = $('#unit-4')
- $ele.offset(
- top: $unit4.offset().top + 8
- left: $ele.offset().left
- )
- # Dragging down, we will insert after.
- destination = Overview.overviewDragger.findDestination($ele, 1)
- expect(destination.ele).toBe($unit4)
- expect(destination.attachMethod).toBe('after')
-
- # Dragging up, we will insert before.
- destination = Overview.overviewDragger.findDestination($ele, -1)
- expect(destination.ele).toBe($unit4)
- expect(destination.attachMethod).toBe('before')
-
- # If past the end the drop target, will attach after.
- $ele.offset(
- top: $unit4.offset().top + $unit4.height() + 1
- left: $ele.offset().left
- )
- destination = Overview.overviewDragger.findDestination($ele, 0)
- expect(destination.ele).toBe($unit4)
- expect(destination.attachMethod).toBe('after')
-
-
- $unit0 = $('#unit-0')
- # If before the start the drop target, will attach before.
- $ele.offset(
- top: $unit0.offset().top - 16
- left: $ele.offset().left
- )
- destination = Overview.overviewDragger.findDestination($ele, 0)
- expect(destination.ele).toBe($unit0)
- expect(destination.attachMethod).toBe('before')
-
- it """can drop before the first element, even if element being dragged is
- slightly before the first element""", ->
- $ele = $('#subsection-2')
- $ele.offset(
- top: $('#subsection-0').offset().top - 5
- left: $ele.offset().left
- )
- destination = Overview.overviewDragger.findDestination($ele, -1)
- expect(destination.ele).toBe($('#subsection-0'))
- expect(destination.attachMethod).toBe('before')
-
- it "can drag and drop across section boundaries, with special handling for last element", ->
- $ele = $('#unit-4')
- $ele.offset(
- top: $('#unit-3').offset().bottom + 4
- left: $ele.offset().left
- )
- destination = Overview.overviewDragger.findDestination($ele, -1)
- expect(destination.ele).toBe($('#unit-3'))
- # Dragging down up into last element, we have a fudge factor makes it easier to drag at beginning.
- expect(destination.attachMethod).toBe('after')
- # Now past the "fudge factor".
- $ele.offset(
- top: $('#unit-3').offset().top + 4
- left: $ele.offset().left
- )
- destination = Overview.overviewDragger.findDestination($ele, -1)
- expect(destination.ele).toBe($('#unit-3'))
- expect(destination.attachMethod).toBe('before')
-
- it """can drop past the last element, even if element being dragged is
- slightly before/taller then the last element""", ->
- $ele = $('#subsection-2')
- $ele.offset(
- # Make the top 1 before the top of the last element in the list.
- # This mimics the problem when the element being dropped is taller then then
- # the last element in the list.
- top: $('#subsection-4').offset().top - 1
- left: $ele.offset().left
- )
- destination = Overview.overviewDragger.findDestination($ele, 1)
- expect(destination.ele).toBe($('#subsection-4'))
- expect(destination.attachMethod).toBe('after')
-
- it "can drag into an empty list", ->
- $ele = $('#unit-1')
- $ele.offset(
- top: $('#subsection-3').offset().top + 10
- left: $ele.offset().left
- )
- destination = Overview.overviewDragger.findDestination($ele, 1)
- expect(destination.ele).toBe($('#subsection-list-3'))
- expect(destination.attachMethod).toBe('prepend')
-
- it "reports a null destination on a failed drag", ->
- $ele = $('#unit-1')
- $ele.offset(
- top: $ele.offset().top + 200, left: $ele.offset().left
- )
- destination = Overview.overviewDragger.findDestination($ele, 1)
- expect(destination).toEqual(
- ele: null
- attachMethod: ""
- )
-
- it "can drag into a collapsed list", ->
- $('#subsection-2').addClass('collapsed')
- $ele = $('#unit-2')
- $ele.offset(
- top: $('#subsection-2').offset().top + 3
- left: $ele.offset().left
- )
- destination = Overview.overviewDragger.findDestination($ele, 1)
- expect(destination.ele).toBe($('#subsection-list-2'))
- expect(destination.parentList).toBe($('#subsection-2'))
- expect(destination.attachMethod).toBe('prepend')
-
- describe "onDragStart", ->
- it "sets the dragState to its default values", ->
- expect(Overview.overviewDragger.dragState).toEqual({})
- # Call with some dummy data
- Overview.overviewDragger.onDragStart(
- {element: $('#unit-1')},
- null,
- null
- )
- expect(Overview.overviewDragger.dragState).toEqual(
- dropDestination: null,
- attachMethod: '',
- parentList: null,
- lastY: 0,
- dragDirection: 0
- )
-
- it "collapses expanded elements", ->
- expect($('#subsection-1')).not.toHaveClass('collapsed')
- Overview.overviewDragger.onDragStart(
- {element: $('#subsection-1')},
- null,
- null
- )
- expect($('#subsection-1')).toHaveClass('collapsed')
- expect($('#subsection-1')).toHaveClass('expand-on-drop')
-
- describe "onDragMove", ->
- beforeEach ->
- @scrollSpy = spyOn(window, 'scrollBy').andCallThrough()
-
- it "adds the correct CSS class to the drop destination", ->
- $ele = $('#unit-1')
- dragY = $ele.offset().top + 10
- dragX = $ele.offset().left
- $ele.offset(
- top: dragY, left: dragX
- )
- Overview.overviewDragger.onDragMove(
- {element: $ele, dragPoint:
- {y: dragY}}, '', {clientX: dragX}
- )
- expect($('#unit-2')).toHaveClass('drop-target drop-target-before')
- expect($ele).toHaveClass('valid-drop')
-
- it "does not add CSS class to the drop destination if out of bounds", ->
- $ele = $('#unit-1')
- dragY = $ele.offset().top + 10
- $ele.offset(
- top: dragY, left: $ele.offset().left
- )
- Overview.overviewDragger.onDragMove(
- {element: $ele, dragPoint:
- {y: dragY}}, '', {clientX: $ele.offset().left - 3}
- )
- expect($('#unit-2')).not.toHaveClass('drop-target drop-target-before')
- expect($ele).not.toHaveClass('valid-drop')
-
- it "scrolls up if necessary", ->
- Overview.overviewDragger.onDragMove(
- {element: $('#unit-1')}, '', {clientY: 2}
- )
- expect(@scrollSpy).toHaveBeenCalledWith(0, -10)
-
- it "scrolls down if necessary", ->
- Overview.overviewDragger.onDragMove(
- {element: $('#unit-1')}, '', {clientY: (window.innerHeight - 5)}
- )
- expect(@scrollSpy).toHaveBeenCalledWith(0, 10)
-
- describe "onDragEnd", ->
- beforeEach ->
- @reorderSpy = spyOn(Overview.overviewDragger, 'handleReorder')
-
- afterEach ->
- @reorderSpy.reset()
-
- it "calls handleReorder on a successful drag", ->
- Overview.overviewDragger.dragState.dropDestination = $('#unit-2')
- Overview.overviewDragger.dragState.attachMethod = "before"
- Overview.overviewDragger.dragState.parentList = $('#subsection-1')
- $('#unit-1').offset(
- top: $('#unit-1').offset().top + 10
- left: $('#unit-1').offset().left
- )
- Overview.overviewDragger.onDragEnd(
- {element: $('#unit-1')},
- null,
- {clientX: $('#unit-1').offset().left}
- )
- expect(@reorderSpy).toHaveBeenCalled()
-
- it "clears out the drag state", ->
- Overview.overviewDragger.onDragEnd(
- {element: $('#unit-1')},
- null,
- null
- )
- expect(Overview.overviewDragger.dragState).toEqual({})
-
- it "sets the element to the correct position", ->
- Overview.overviewDragger.onDragEnd(
- {element: $('#unit-1')},
- null,
- null
- )
- # Chrome sets the CSS to 'auto', but Firefox uses '0px'.
- expect(['0px', 'auto']).toContain($('#unit-1').css('top'))
- expect(['0px', 'auto']).toContain($('#unit-1').css('left'))
-
- it "expands an element if it was collapsed on drag start", ->
- $('#subsection-1').addClass('collapsed')
- $('#subsection-1').addClass('expand-on-drop')
- Overview.overviewDragger.onDragEnd(
- {element: $('#subsection-1')},
- null,
- null
- )
- expect($('#subsection-1')).not.toHaveClass('collapsed')
- expect($('#subsection-1')).not.toHaveClass('expand-on-drop')
-
- it "expands a collapsed element when something is dropped in it", ->
- $('#subsection-2').addClass('collapsed')
- Overview.overviewDragger.dragState.dropDestination = $('#list-2')
- Overview.overviewDragger.dragState.attachMethod = "prepend"
- Overview.overviewDragger.dragState.parentList = $('#subsection-2')
- Overview.overviewDragger.onDragEnd(
- {element: $('#unit-1')},
- null,
- {clientX: $('#unit-1').offset().left}
- )
- expect($('#subsection-2')).not.toHaveClass('collapsed')
-
- describe "AJAX", ->
- beforeEach ->
- @savingSpies = spyOnConstructor(Notification, "Mini",
- ["show", "hide"])
- @savingSpies.show.andReturn(@savingSpies)
- @clock = sinon.useFakeTimers()
-
- afterEach ->
- @clock.restore()
-
- it "should send an update on reorder", ->
- requests = create_sinon["requests"](this)
-
- Overview.overviewDragger.dragState.dropDestination = $('#unit-4')
- Overview.overviewDragger.dragState.attachMethod = "after"
- Overview.overviewDragger.dragState.parentList = $('#subsection-2')
- # Drag Unit 1 from Subsection 1 to the end of Subsection 2.
- $('#unit-1').offset(
- top: $('#unit-4').offset().top + 10
- left: $('#unit-4').offset().left
- )
- Overview.overviewDragger.onDragEnd(
- {element: $('#unit-1')},
- null,
- {clientX: $('#unit-1').offset().left}
- )
- expect(requests.length).toEqual(2)
- expect(@savingSpies.constructor).toHaveBeenCalled()
- expect(@savingSpies.show).toHaveBeenCalled()
- expect(@savingSpies.hide).not.toHaveBeenCalled()
- savingOptions = @savingSpies.constructor.mostRecentCall.args[0]
- expect(savingOptions.title).toMatch(/Saving/)
- expect($('#unit-1')).toHaveClass('was-dropped')
- # We expect 2 requests to be sent-- the first for removing Unit 1 from Subsection 1,
- # and the second for adding Unit 1 to the end of Subsection 2.
- expect(requests[0].requestBody).toEqual('{"children":["second-unit-id","third-unit-id"]}')
- requests[0].respond(200)
- expect(@savingSpies.hide).not.toHaveBeenCalled()
- expect(requests[1].requestBody).toEqual('{"children":["fourth-unit-id","first-unit-id"]}')
- requests[1].respond(200)
- expect(@savingSpies.hide).toHaveBeenCalled()
- # Class is removed in a timeout.
- @clock.tick(1001)
- expect($('#unit-1')).not.toHaveClass('was-dropped')
diff --git a/cms/static/js/spec/utils/drag_and_drop_spec.js b/cms/static/js/spec/utils/drag_and_drop_spec.js
new file mode 100644
index 0000000000..74558ca22f
--- /dev/null
+++ b/cms/static/js/spec/utils/drag_and_drop_spec.js
@@ -0,0 +1,313 @@
+define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec/create_sinon", "jquery"],
+ function (ContentDragger, Notification, create_sinon, $) {
+ describe("Overview drag and drop functionality", function () {
+ beforeEach(function () {
+ setFixtures(readFixtures('mock/mock-outline.underscore'));
+ ContentDragger.makeDraggable('.unit', '.unit-drag-handle', 'ol.sortable-unit-list', 'li.courseware-subsection, article.subsection-body');
+ ContentDragger.makeDraggable('.courseware-subsection', '.subsection-drag-handle', '.sortable-subsection-list', 'section');
+ });
+
+ describe("findDestination", function () {
+ it("correctly finds the drop target of a drag", function () {
+ var $ele, destination;
+ $ele = $('#unit-1');
+ $ele.offset({
+ top: $ele.offset().top + 10,
+ left: $ele.offset().left
+ });
+ destination = ContentDragger.findDestination($ele, 1);
+ expect(destination.ele).toBe($('#unit-2'));
+ expect(destination.attachMethod).toBe('before');
+ });
+ it("can drag and drop across section boundaries, with special handling for single sibling", function () {
+ var $ele, $unit0, $unit4, destination;
+ $ele = $('#unit-1');
+ $unit4 = $('#unit-4');
+ $ele.offset({
+ top: $unit4.offset().top + 8,
+ left: $ele.offset().left
+ });
+ destination = ContentDragger.findDestination($ele, 1);
+ expect(destination.ele).toBe($unit4);
+ expect(destination.attachMethod).toBe('after');
+ destination = ContentDragger.findDestination($ele, -1);
+ expect(destination.ele).toBe($unit4);
+ expect(destination.attachMethod).toBe('before');
+ $ele.offset({
+ top: $unit4.offset().top + $unit4.height() + 1,
+ left: $ele.offset().left
+ });
+ destination = ContentDragger.findDestination($ele, 0);
+ expect(destination.ele).toBe($unit4);
+ expect(destination.attachMethod).toBe('after');
+ $unit0 = $('#unit-0');
+ $ele.offset({
+ top: $unit0.offset().top - 16,
+ left: $ele.offset().left
+ });
+ destination = ContentDragger.findDestination($ele, 0);
+ expect(destination.ele).toBe($unit0);
+ expect(destination.attachMethod).toBe('before');
+ });
+ it("can drop before the first element, even if element being dragged is\nslightly before the first element", function () {
+ var $ele, destination;
+ $ele = $('#subsection-2');
+ $ele.offset({
+ top: $('#subsection-0').offset().top - 5,
+ left: $ele.offset().left
+ });
+ destination = ContentDragger.findDestination($ele, -1);
+ expect(destination.ele).toBe($('#subsection-0'));
+ expect(destination.attachMethod).toBe('before');
+ });
+ it("can drag and drop across section boundaries, with special handling for last element", function () {
+ var $ele, destination;
+ $ele = $('#unit-4');
+ $ele.offset({
+ top: $('#unit-3').offset().bottom + 4,
+ left: $ele.offset().left
+ });
+ destination = ContentDragger.findDestination($ele, -1);
+ expect(destination.ele).toBe($('#unit-3'));
+ expect(destination.attachMethod).toBe('after');
+ $ele.offset({
+ top: $('#unit-3').offset().top + 4,
+ left: $ele.offset().left
+ });
+ destination = ContentDragger.findDestination($ele, -1);
+ expect(destination.ele).toBe($('#unit-3'));
+ expect(destination.attachMethod).toBe('before');
+ });
+ it("can drop past the last element, even if element being dragged is\nslightly before/taller then the last element", function () {
+ var $ele, destination;
+ $ele = $('#subsection-2');
+ $ele.offset({
+ top: $('#subsection-4').offset().top - 1,
+ left: $ele.offset().left
+ });
+ destination = ContentDragger.findDestination($ele, 1);
+ expect(destination.ele).toBe($('#subsection-4'));
+ expect(destination.attachMethod).toBe('after');
+ });
+ it("can drag into an empty list", function () {
+ var $ele, destination;
+ $ele = $('#unit-1');
+ $ele.offset({
+ top: $('#subsection-3').offset().top + 10,
+ left: $ele.offset().left
+ });
+ destination = ContentDragger.findDestination($ele, 1);
+ expect(destination.ele).toBe($('#subsection-list-3'));
+ expect(destination.attachMethod).toBe('prepend');
+ });
+ it("reports a null destination on a failed drag", function () {
+ var $ele, destination;
+ $ele = $('#unit-1');
+ $ele.offset({
+ top: $ele.offset().top + 200,
+ left: $ele.offset().left
+ });
+ destination = ContentDragger.findDestination($ele, 1);
+ expect(destination).toEqual({
+ ele: null,
+ attachMethod: ""
+ });
+ });
+ it("can drag into a collapsed list", function () {
+ var $ele, destination;
+ $('#subsection-2').addClass('collapsed');
+ $ele = $('#unit-2');
+ $ele.offset({
+ top: $('#subsection-2').offset().top + 3,
+ left: $ele.offset().left
+ });
+ destination = ContentDragger.findDestination($ele, 1);
+ expect(destination.ele).toBe($('#subsection-list-2'));
+ expect(destination.parentList).toBe($('#subsection-2'));
+ expect(destination.attachMethod).toBe('prepend');
+ });
+ });
+ describe("onDragStart", function () {
+ it("sets the dragState to its default values", function () {
+ expect(ContentDragger.dragState).toEqual({});
+ ContentDragger.onDragStart({
+ element: $('#unit-1')
+ }, null, null);
+ expect(ContentDragger.dragState).toEqual({
+ dropDestination: null,
+ attachMethod: '',
+ parentList: null,
+ lastY: 0,
+ dragDirection: 0
+ });
+ });
+ it("collapses expanded elements", function () {
+ expect($('#subsection-1')).not.toHaveClass('collapsed');
+ ContentDragger.onDragStart({
+ element: $('#subsection-1')
+ }, null, null);
+ expect($('#subsection-1')).toHaveClass('collapsed');
+ expect($('#subsection-1')).toHaveClass('expand-on-drop');
+ });
+ });
+ describe("onDragMove", function () {
+ beforeEach(function () {
+ this.scrollSpy = spyOn(window, 'scrollBy').andCallThrough();
+ });
+ it("adds the correct CSS class to the drop destination", function () {
+ var $ele, dragX, dragY;
+ $ele = $('#unit-1');
+ dragY = $ele.offset().top + 10;
+ dragX = $ele.offset().left;
+ $ele.offset({
+ top: dragY,
+ left: dragX
+ });
+ ContentDragger.onDragMove({
+ element: $ele,
+ dragPoint: {
+ y: dragY
+ }
+ }, '', {
+ clientX: dragX
+ });
+ expect($('#unit-2')).toHaveClass('drop-target drop-target-before');
+ expect($ele).toHaveClass('valid-drop');
+ });
+ it("does not add CSS class to the drop destination if out of bounds", function () {
+ var $ele, dragY;
+ $ele = $('#unit-1');
+ dragY = $ele.offset().top + 10;
+ $ele.offset({
+ top: dragY,
+ left: $ele.offset().left
+ });
+ ContentDragger.onDragMove({
+ element: $ele,
+ dragPoint: {
+ y: dragY
+ }
+ }, '', {
+ clientX: $ele.offset().left - 3
+ });
+ expect($('#unit-2')).not.toHaveClass('drop-target drop-target-before');
+ expect($ele).not.toHaveClass('valid-drop');
+ });
+ it("scrolls up if necessary", function () {
+ ContentDragger.onDragMove({
+ element: $('#unit-1')
+ }, '', {
+ clientY: 2
+ });
+ expect(this.scrollSpy).toHaveBeenCalledWith(0, -10);
+ });
+ it("scrolls down if necessary", function () {
+ ContentDragger.onDragMove({
+ element: $('#unit-1')
+ }, '', {
+ clientY: window.innerHeight - 5
+ });
+ expect(this.scrollSpy).toHaveBeenCalledWith(0, 10);
+ });
+ });
+ describe("onDragEnd", function () {
+ beforeEach(function () {
+ this.reorderSpy = spyOn(ContentDragger, 'handleReorder');
+ });
+ afterEach(function () {
+ this.reorderSpy.reset();
+ });
+ it("calls handleReorder on a successful drag", function () {
+ ContentDragger.dragState.dropDestination = $('#unit-2');
+ ContentDragger.dragState.attachMethod = "before";
+ ContentDragger.dragState.parentList = $('#subsection-1');
+ $('#unit-1').offset({
+ top: $('#unit-1').offset().top + 10,
+ left: $('#unit-1').offset().left
+ });
+ ContentDragger.onDragEnd({
+ element: $('#unit-1')
+ }, null, {
+ clientX: $('#unit-1').offset().left
+ });
+ expect(this.reorderSpy).toHaveBeenCalled();
+ });
+ it("clears out the drag state", function () {
+ ContentDragger.onDragEnd({
+ element: $('#unit-1')
+ }, null, null);
+ expect(ContentDragger.dragState).toEqual({});
+ });
+ it("sets the element to the correct position", function () {
+ ContentDragger.onDragEnd({
+ element: $('#unit-1')
+ }, null, null);
+ expect(['0px', 'auto']).toContain($('#unit-1').css('top'));
+ expect(['0px', 'auto']).toContain($('#unit-1').css('left'));
+ });
+ it("expands an element if it was collapsed on drag start", function () {
+ $('#subsection-1').addClass('collapsed');
+ $('#subsection-1').addClass('expand-on-drop');
+ ContentDragger.onDragEnd({
+ element: $('#subsection-1')
+ }, null, null);
+ expect($('#subsection-1')).not.toHaveClass('collapsed');
+ expect($('#subsection-1')).not.toHaveClass('expand-on-drop');
+ });
+ it("expands a collapsed element when something is dropped in it", function () {
+ $('#subsection-2').addClass('collapsed');
+ ContentDragger.dragState.dropDestination = $('#list-2');
+ ContentDragger.dragState.attachMethod = "prepend";
+ ContentDragger.dragState.parentList = $('#subsection-2');
+ ContentDragger.onDragEnd({
+ element: $('#unit-1')
+ }, null, {
+ clientX: $('#unit-1').offset().left
+ });
+ expect($('#subsection-2')).not.toHaveClass('collapsed');
+ });
+ });
+ describe("AJAX", function () {
+ beforeEach(function () {
+ this.savingSpies = spyOnConstructor(Notification, "Mini", ["show", "hide"]);
+ this.savingSpies.show.andReturn(this.savingSpies);
+ this.clock = sinon.useFakeTimers();
+ });
+ afterEach(function () {
+ this.clock.restore();
+ });
+ it("should send an update on reorder", function () {
+ var requests, savingOptions;
+ requests = create_sinon["requests"](this);
+ ContentDragger.dragState.dropDestination = $('#unit-4');
+ ContentDragger.dragState.attachMethod = "after";
+ ContentDragger.dragState.parentList = $('#subsection-2');
+ $('#unit-1').offset({
+ top: $('#unit-4').offset().top + 10,
+ left: $('#unit-4').offset().left
+ });
+ ContentDragger.onDragEnd({
+ element: $('#unit-1')
+ }, null, {
+ clientX: $('#unit-1').offset().left
+ });
+ expect(requests.length).toEqual(2);
+ expect(this.savingSpies.constructor).toHaveBeenCalled();
+ expect(this.savingSpies.show).toHaveBeenCalled();
+ expect(this.savingSpies.hide).not.toHaveBeenCalled();
+ savingOptions = this.savingSpies.constructor.mostRecentCall.args[0];
+ expect(savingOptions.title).toMatch(/Saving/);
+ expect($('#unit-1')).toHaveClass('was-dropped');
+ expect(requests[0].requestBody).toEqual('{"children":["second-unit-id","third-unit-id"]}');
+ requests[0].respond(200);
+ expect(this.savingSpies.hide).not.toHaveBeenCalled();
+ expect(requests[1].requestBody).toEqual('{"children":["fourth-unit-id","first-unit-id"]}');
+ requests[1].respond(200);
+ expect(this.savingSpies.hide).toHaveBeenCalled();
+ this.clock.tick(1001);
+ expect($('#unit-1')).not.toHaveClass('was-dropped');
+ });
+ });
+ });
+ });
+
diff --git a/cms/static/js/spec/utils/handle_iframe_binding_spec.js b/cms/static/js/spec/utils/handle_iframe_binding_spec.js
index 2375d08083..04dd27d183 100644
--- a/cms/static/js/spec/utils/handle_iframe_binding_spec.js
+++ b/cms/static/js/spec/utils/handle_iframe_binding_spec.js
@@ -12,6 +12,15 @@ function ($, _, IframeBinding) {
iframe_html += '