diff --git a/cms/static/js/spec/utils/drag_and_drop_spec.js b/cms/static/js/spec/utils/drag_and_drop_spec.js index c121f1869a..93e4b23340 100644 --- a/cms/static/js/spec/utils/drag_and_drop_spec.js +++ b/cms/static/js/spec/utils/drag_and_drop_spec.js @@ -11,7 +11,8 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel handleClass: '.unit-drag-handle', droppableClass: 'ol.sortable-unit-list', parentLocationSelector: 'li.courseware-subsection', - refresh: jasmine.createSpy('Spy on Unit') + refresh: jasmine.createSpy('Spy on Unit'), + ensureChildrenRendered: jasmine.createSpy('Spy on Unit') }); } ); @@ -23,7 +24,8 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel handleClass: '.subsection-drag-handle', droppableClass: '.sortable-subsection-list', parentLocationSelector: 'section', - refresh: jasmine.createSpy('Spy on Subsection') + refresh: jasmine.createSpy('Spy on Subsection'), + ensureChildrenRendered: jasmine.createSpy('Spy on Subsection') }); } ); @@ -277,6 +279,10 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel expect($('#subsection-1')).not.toHaveClass('expand-on-drop'); }); it("expands a collapsed element when something is dropped in it", function () { + expandElementSpy = spyOn(ContentDragger, 'expandElement').andCallThrough(); + expect(expandElementSpy).not.toHaveBeenCalled(); + expect($('#subsection-2').data('ensureChildrenRendered')).not.toHaveBeenCalled(); + $('#subsection-2').addClass('is-collapsed'); ContentDragger.dragState.dropDestination = $('#list-2'); ContentDragger.dragState.attachMethod = "prepend"; @@ -286,6 +292,10 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel }, null, { clientX: $('#unit-1').offset().left }); + + // verify collapsed element expands while ensuring its children are properly rendered + expect(expandElementSpy).toHaveBeenCalled(); + expect($('#subsection-2').data('ensureChildrenRendered')).toHaveBeenCalled(); expect($('#subsection-2')).not.toHaveClass('is-collapsed'); }); }); diff --git a/common/test/acceptance/pages/studio/overview.py b/common/test/acceptance/pages/studio/overview.py index 11e0b2b69f..bc99be3823 100644 --- a/common/test/acceptance/pages/studio/overview.py +++ b/common/test/acceptance/pages/studio/overview.py @@ -8,10 +8,11 @@ from bok_choy.promise import EmptyPromise from selenium.webdriver.support.ui import Select from selenium.webdriver.common.keys import Keys +from selenium.webdriver.common.action_chains import ActionChains from .course_page import CoursePage from .container import ContainerPage -from .utils import set_input_value_and_save, set_input_value, click_css, confirm_prompt +from .utils import set_input_value_and_save, set_input_value, click_css, confirm_prompt, wait_for_notification class CourseOutlineItem(object): @@ -510,6 +511,86 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): if subsection.is_collapsed: subsection.toggle_expand() + @property + def outline_items(self): + """ + Return a list of xblocks loaded on the outline page. + """ + return self._get_outline_items() + + def _get_outline_items(self, prefix=""): + return self.q(css=prefix + OutlineWrapper.BODY_SELECTOR).map( + lambda el: OutlineWrapper(self.browser, el.get_attribute('data-locator'))).results + + def drag(self, source_index, target_index): + """ + 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 BEFORE the + one with the target_index drag handle. + """ + draggables = self.q(css='.drag-handle') + source = draggables[source_index] + target = draggables[target_index] + action = ActionChains(self.browser) + # When dragging before the target element, must take into account that the placeholder + # will appear in the place where the target used to be. + placeholder_height = 40 + action.click_and_hold(source).move_to_element_with_offset( + target, 0, placeholder_height + ).release().perform() + wait_for_notification(self) + + +class OutlineWrapper(PageObject): + """ + A PageObject representing a wrapper around course outline items shown on the Course Outline page. + """ + url = None + BODY_SELECTOR = '.outline-item' + NAME_SELECTOR = '.item-title' + + def __init__(self, browser, locator): + super(OutlineWrapper, self).__init__(browser) + self.locator = locator + + def is_browser_on_page(self): + return self.q(css='{}[data-locator="{}"]'.format(self.BODY_SELECTOR, self.locator)).present + + def _bounded_selector(self, selector): + """ + Return `selector`, but limited to this particular `CourseOutlineChild` context + """ + return '{}[data-locator="{}"] {}'.format( + self.BODY_SELECTOR, + self.locator, + selector + ) + + @property + def name(self): + titles = self.q(css=self._bounded_selector(self.NAME_SELECTOR)).text + if titles: + return titles[0] + else: + return None + + @property + def children(self): + """ + Will return any first-generation descendant items of this item. + """ + descendants = self.q(css=self._bounded_selector(self.BODY_SELECTOR)).map( + lambda el: OutlineWrapper(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] class CourseOutlineModal(object): MODAL_SELECTOR = ".wrapper-modal-window" diff --git a/common/test/acceptance/tests/test_studio_outline.py b/common/test/acceptance/tests/test_studio_outline.py index 4575971022..5d42378364 100644 --- a/common/test/acceptance/tests/test_studio_outline.py +++ b/common/test/acceptance/tests/test_studio_outline.py @@ -49,10 +49,107 @@ class CourseOutlineTest(StudioCourseTest): XBlockFixtureDesc('html', 'Test HTML Component'), XBlockFixtureDesc('discussion', 'Test Discussion Component') ) + ), + XBlockFixtureDesc('sequential', "DropS").add_children( + XBlockFixtureDesc('vertical', "DropV").add_children( + XBlockFixtureDesc('problem', 'Drop Problem 1', data=load_data_str('multiple_choice.xml')), + ) ) ) ) + def verify_ordering(self, outline_page, expected_orderings): + """ + Verifies the expected ordering of xblocks on the page. + """ + xblocks = outline_page.outline_items + blocks_checked = set() + for expected_ordering in expected_orderings: + for xblock in xblocks: + parent = expected_ordering.keys()[0] + if xblock.name == parent: + blocks_checked.add(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) + blocks_checked.add(expected) + break + self.assertEqual(len(blocks_checked), len(xblocks)) + + def do_action_and_verify(self, outline_page, action, expected_ordering): + """ + Perform the supplied action and then verify the resulting ordering. + """ + if outline_page is None: + outline_page = self.course_outline_page.visit() + + action(outline_page) + self.verify_ordering(outline_page, expected_ordering) + + # Reload the page and expand all subsections to see that the change was persisted. + course_outline_page = self.course_outline_page.visit() + course_outline_page.q(css='.outline-item.outline-subsection.is-collapsed .ui-toggle-expansion').click() + self.verify_ordering(course_outline_page, expected_ordering) + + +@attr('shard_2') +class CourseOutlineDragAndDropTest(CourseOutlineTest): + """ + Tests of drag and drop within the outline page. + """ + __test__ = True + + def populate_course_fixture(self, course_fixture): + """ + Create a course with one section, two subsections, and four units + """ + # with collapsed outline + self.chap_1_handle = 0 + self.chap_1_seq_1_handle = 1 + + # with first sequential expanded + self.seq_1_vert_1_handle = 2 + self.seq_1_vert_2_handle = 3 + self.chap_1_seq_2_handle = 4 + + course_fixture.add_children( + XBlockFixtureDesc('chapter', "1").add_children( + XBlockFixtureDesc('sequential', '1.1').add_children( + XBlockFixtureDesc('vertical', '1.1.1'), + XBlockFixtureDesc('vertical', '1.1.2') + ), + XBlockFixtureDesc('sequential', '1.2').add_children( + XBlockFixtureDesc('vertical', '1.2.1'), + XBlockFixtureDesc('vertical', '1.2.2') + ) + ) + ) + + def drag_and_verify(self, source, target, expected_ordering, outline_page=None): + self.do_action_and_verify( + outline_page, + lambda (outline): outline.drag(source, target), + expected_ordering + ) + + def test_drop_unit_in_collapsed_subsection(self): + """ + Drag vertical "1.1.2" from subsection "1.1" into collapsed subsection "1.2" which already + have its own verticals. + """ + course_outline_page = self.course_outline_page.visit() + # expand first subsection + course_outline_page.q(css='.outline-item.outline-subsection.is-collapsed .ui-toggle-expansion').first.click() + + expected_ordering = [{"1": ["1.1", "1.2"]}, + {"1.1": ["1.1.1"]}, + {"1.2": ["1.1.2", "1.2.1", "1.2.2"]}] + self.drag_and_verify(self.seq_1_vert_2_handle, self.chap_1_seq_2_handle, expected_ordering, course_outline_page) + @attr('shard_2') class WarningMessagesTest(CourseOutlineTest):