Commit 74a9989f by Nimisha Asthagiri

Merge pull request #11705 from edx/tnl/next_button

Activate LMS Navigation Button across sections
parents dab77fda 2f037f04
......@@ -30,6 +30,23 @@ $link-color: rgb(26, 161, 222) !default;
}
}
%ui-clear-button {
background-color: rgba(0,0,0,0);
background-image: none;
background-position: center 14px;
background-repeat: no-repeat;
border: none;
border-radius: 0;
background-clip: border-box;
box-shadow: none;
box-sizing: content-box;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
}
// ====================
.sequence-nav {
......@@ -90,7 +107,7 @@ $link-color: rgb(26, 161, 222) !default;
padding: 0 ($baseline/2);
width: 100%;
a {
button {
@extend .block-link;
}
......@@ -98,15 +115,14 @@ $link-color: rgb(26, 161, 222) !default;
display: table-cell;
min-width: 20px;
a {
button {
@extend %ui-fake-link;
@extend %ui-clear-button;
@include transition(none);
width: 100%;
height: 42px;
margin: 0;
background-position: center 14px;
background-repeat: no-repeat;
border: 1px solid transparent;
display: block;
padding: 0;
position: relative;
......@@ -197,7 +213,6 @@ $link-color: rgb(26, 161, 222) !default;
.sequence-tooltip {
@extend %ui-depth2;
display: none;
margin-top: ($baseline/5);
background: #333;
color: $white;
......@@ -209,7 +224,6 @@ $link-color: rgb(26, 161, 222) !default;
top: 48px;
text-shadow: 0 -1px 0 $black;
@include text-align(left);
@include transition(all .1s $ease-in-out-quart 0s);
white-space: pre;
pointer-events: none;
......@@ -233,17 +247,11 @@ $link-color: rgb(26, 161, 222) !default;
width: 10px;
}
}
&:hover, &:focus {
.sequence-tooltip {
display: block;
}
}
}
}
}
body.touch-based-device & ol li a:hover p {
body.touch-based-device & ol li button:hover p {
display: none;
}
}
......
......@@ -4,8 +4,8 @@
</ol>
<ul class="sequence-nav-buttons">
<li class="prev"><a href="#">Previous</a></li>
<li class="next"><a href="#">Next</a></li>
<li class="prev"><button>Previous</button></li>
<li class="next"><button>Next</button></li>
</ul>
</nav>
......@@ -13,8 +13,8 @@
<nav class="sequence-bottom">
<ul class="sequence-nav-buttons">
<li class="prev"><a href="#">Previous</a></li>
<li class="next"><a href="#">Next</a></li>
<li class="prev"><button>Previous</button></li>
<li class="next"><button>Next</button></li>
</ul>
</nav>
</div>
......@@ -8,6 +8,8 @@ class @Sequence
@num_contents = @contents.length
@id = @el.data('id')
@ajaxUrl = @el.data('ajax-url')
@nextUrl = @el.data('next-url')
@prevUrl = @el.data('prev-url')
@base_page_title = " | " + document.title
@initProgress()
@bind()
......@@ -17,9 +19,17 @@ class @Sequence
$(selector, @el)
bind: ->
@$('#sequence-list a').click @goto
@$('#sequence-list .nav-item').click @goto
@el.on 'bookmark:add', @addBookmarkIconToActiveNavItem
@el.on 'bookmark:remove', @removeBookmarkIconFromActiveNavItem
@$('#sequence-list .nav-item').on('focus mouseenter', @displayTabTooltip)
@$('#sequence-list .nav-item').on('blur mouseleave', @hideTabTooltip)
displayTabTooltip: (event) =>
$(event.currentTarget).find('.sequence-tooltip').removeClass('sr')
hideTabTooltip: (event) =>
$(event.currentTarget).find('.sequence-tooltip').addClass('sr')
initProgress: ->
@progressTable = {} # "#problem_#{id}" -> progress
......@@ -73,23 +83,35 @@ class @Sequence
when 'in_progress' then element.addClass('progress-some')
when 'done' then element.addClass('progress-done')
toggleArrows: =>
@$('.sequence-nav-button').unbind('click')
enableButton: (button_class, button_action) ->
@$(button_class).removeClass('disabled').removeAttr('disabled').click(button_action)
if @contents.length == 0 ## There are no modules to display, and therefore no nav to build.
@$('.sequence-nav-button.button-previous').addClass('disabled').attr('disabled', true)
@$('.sequence-nav-button.button-next').addClass('disabled').attr('disabled', true)
return
disableButton: (button_class) ->
@$(button_class).addClass('disabled').attr('disabled', true)
if @position == 1 ## 1 != 0 here. 1 is the first item in the sequence nav.
@$('.sequence-nav-button.button-previous').addClass('disabled').attr('disabled', true)
else
@$('.sequence-nav-button.button-previous').removeClass('disabled').removeAttr('disabled').click(@previous)
setButtonLabel: (button_class, button_label) ->
@$(button_class + ' .sr').html(button_label)
if @position == @contents.length ## If the final position on the nav matches the total contents.
@$('.sequence-nav-button.button-next').addClass('disabled').attr('disabled', true)
updateButtonState: (button_class, button_action, action_label_prefix, is_at_boundary, boundary_url) ->
if is_at_boundary and boundary_url == 'None'
@disableButton(button_class)
else
@$('.sequence-nav-button.button-next').removeClass('disabled').removeAttr('disabled').click(@next)
button_label = action_label_prefix + (if is_at_boundary then ' Section' else ' Unit')
@setButtonLabel(button_class, button_label)
@enableButton(button_class, button_action)
toggleArrows: =>
@$('.sequence-nav-button').unbind('click')
# previous button
first_tab = @position == 1
previous_button_class = '.sequence-nav-button.button-previous'
@updateButtonState(previous_button_class, @previous, 'Previous', first_tab, @prevUrl)
# next button
last_tab = @position >= @contents.length # use inequality in case contents.length is 0 and position is 1.
next_button_class = '.sequence-nav-button.button-next'
@updateButtonState(next_button_class, @next, 'Next', last_tab, @nextUrl)
render: (new_position) ->
if @position != new_position
......@@ -122,7 +144,6 @@ class @Sequence
@el.find('.path').text(@el.find('.nav-item.active').data('path'))
@sr_container.focus();
# @$("a.active").blur()
goto: (event) =>
event.preventDefault()
......@@ -164,13 +185,18 @@ class @Sequence
new: new_position
id: @id
# If the bottom nav is used, scroll to the top of the page on change.
if $(event.target).closest('nav[class="sequence-bottom"]').length > 0
$.scrollTo 0, 150
@render new_position
if (direction == "seq_next") and (@position == @contents.length)
window.location.href = @nextUrl
else if (direction == "seq_prev") and (@position == 1)
window.location.href = @prevUrl
else
# If the bottom nav is used, scroll to the top of the page on change.
if $(event.target).closest('nav[class="sequence-bottom"]').length > 0
$.scrollTo 0, 150
@render new_position
link_for: (position) ->
@$("#sequence-list a[data-element=#{position}]")
@$("#sequence-list .nav-item[data-element=#{position}]")
mark_visited: (position) ->
# Don't overwrite class attribute to avoid changing Progress class
......
......@@ -141,16 +141,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
# If position is specified in system, then use that instead.
position = getattr(self.system, 'position', None)
if position is not None:
try:
self.position = int(self.system.position)
except (ValueError, TypeError):
# Check for https://openedx.atlassian.net/browse/LMS-6496
warnings.warn(
"Sequential position cannot be converted to an integer: {pos!r}".format(
pos=self.system.position,
),
RuntimeWarning,
)
assert isinstance(position, int)
self.position = self.system.position
def get_progress(self):
''' Return the total progress, adding total done and total available.
......@@ -177,9 +169,16 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
raise NotFoundError('Unexpected dispatch type')
def student_view(self, context):
display_items = self.get_display_items()
# If we're rendering this sequence, but no position is set yet,
# or exceeds the length of the displayable items,
# default the position to the first element
if self.position is None:
if context.get('requested_child') == 'first':
self.position = 1
elif context.get('requested_child') == 'last':
self.position = len(display_items) or None
elif self.position is None or self.position > len(display_items):
self.position = 1
## Returns a set of all types of all sub-children
......@@ -211,7 +210,6 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
fragment.add_content(view_html)
return fragment
display_items = self.get_display_items()
for child in display_items:
is_bookmarked = bookmarks_service.is_bookmarked(usage_key=child.scope_ids.usage_id)
context["bookmarked"] = is_bookmarked
......@@ -245,6 +243,16 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
'position': self.position,
'tag': self.location.category,
'ajax_url': self.system.ajax_url,
'next_url': _compute_next_url(
self.location,
parent_module,
context.get('redirect_url_func'),
),
'prev_url': _compute_previous_url(
self.location,
parent_module,
context.get('redirect_url_func'),
),
}
fragment.add_content(self.system.render_template("seq_module.html", params))
......@@ -453,3 +461,88 @@ class SequenceDescriptor(SequenceFields, ProctoringFields, MakoModuleDescriptor,
xblock_body["content_type"] = "Sequence"
return xblock_body
def _compute_next_url(block_location, parent_block, redirect_url_func):
"""
Returns the url for the next block after the given block.
"""
def get_next_block_location(parent_block, index_in_parent):
"""
Returns the next block in the parent_block after the block with the given
index_in_parent.
"""
if index_in_parent + 1 < len(parent_block.children):
return parent_block.children[index_in_parent + 1]
else:
return None
return _compute_next_or_prev_url(
block_location,
parent_block,
redirect_url_func,
get_next_block_location,
'first',
)
def _compute_previous_url(block_location, parent_block, redirect_url_func):
"""
Returns the url for the previous block after the given block.
"""
def get_previous_block_location(parent_block, index_in_parent):
"""
Returns the previous block in the parent_block before the block with the given
index_in_parent.
"""
return parent_block.children[index_in_parent - 1] if index_in_parent else None
return _compute_next_or_prev_url(
block_location,
parent_block,
redirect_url_func,
get_previous_block_location,
'last',
)
def _compute_next_or_prev_url(
block_location,
parent_block,
redirect_url_func,
get_next_or_prev_block,
redirect_url_child_param,
):
"""
Returns the url for the next or previous block from the given block.
Arguments:
block_location: Location of the block that is being navigated.
parent_block: Parent block of the given block.
redirect_url_func: Function that computes a redirect URL directly to
a block, given the block's location.
get_next_or_prev_block: Function that returns the next or previous
block in the parent, or None if doesn't exist.
redirect_url_child_param: Value to pass for the child parameter to the
redirect_url_func.
"""
if redirect_url_func:
index_in_parent = parent_block.children.index(block_location)
next_or_prev_block_location = get_next_or_prev_block(parent_block, index_in_parent)
if next_or_prev_block_location:
return redirect_url_func(
block_location.course_key,
next_or_prev_block_location,
child=redirect_url_child_param,
)
else:
grandparent = parent_block.get_parent()
if grandparent:
return _compute_next_or_prev_url(
parent_block.location,
grandparent,
redirect_url_func,
get_next_or_prev_block,
redirect_url_child_param,
)
return None
"""
Tests for sequence module.
"""
# pylint: disable=no-member
from mock import Mock
from xblock.reference.user_service import XBlockUser, UserService
from xmodule.tests import get_test_system
from xmodule.tests.xml import XModuleXmlImportTest
from xmodule.tests.xml import factories as xml
from xmodule.x_module import STUDENT_VIEW
from xmodule.seq_module import _compute_next_url, _compute_previous_url, SequenceModule
class StubUserService(UserService):
"""
Stub UserService for testing the sequence module.
"""
def get_current_user(self):
"""
Implements abstract method for getting the current user.
"""
user = XBlockUser()
user.opt_attrs['edx-platform.username'] = 'test user'
return user
class SequenceBlockTestCase(XModuleXmlImportTest):
"""
Tests for the Sequence Module.
"""
@classmethod
def setUpClass(cls):
super(SequenceBlockTestCase, cls).setUpClass()
course_xml = cls._set_up_course_xml()
cls.course = cls.process_xml(course_xml)
cls._set_up_module_system(cls.course)
for chapter_index in range(len(cls.course.get_children())):
chapter = cls._set_up_block(cls.course, chapter_index)
setattr(cls, 'chapter_{}'.format(chapter_index + 1), chapter)
for sequence_index in range(len(chapter.get_children())):
sequence = cls._set_up_block(chapter, sequence_index)
setattr(cls, 'sequence_{}_{}'.format(chapter_index + 1, sequence_index + 1), sequence)
@classmethod
def _set_up_course_xml(cls):
"""
Sets up and returns XML course structure.
"""
course = xml.CourseFactory.build()
chapter_1 = xml.ChapterFactory.build(parent=course) # has 2 child sequences
xml.ChapterFactory.build(parent=course) # has 0 child sequences
chapter_3 = xml.ChapterFactory.build(parent=course) # has 1 child sequence
chapter_4 = xml.ChapterFactory.build(parent=course) # has 2 child sequences
xml.SequenceFactory.build(parent=chapter_1)
xml.SequenceFactory.build(parent=chapter_1)
sequence_3_1 = xml.SequenceFactory.build(parent=chapter_3) # has 3 verticals
xml.SequenceFactory.build(parent=chapter_4)
xml.SequenceFactory.build(parent=chapter_4)
for _ in range(3):
xml.VerticalFactory.build(parent=sequence_3_1)
return course
@classmethod
def _set_up_block(cls, parent, index_in_parent):
"""
Sets up the stub sequence module for testing.
"""
block = parent.get_children()[index_in_parent]
cls._set_up_module_system(block)
block.xmodule_runtime._services['bookmarks'] = Mock() # pylint: disable=protected-access
block.xmodule_runtime._services['user'] = StubUserService() # pylint: disable=protected-access
block.xmodule_runtime.xmodule_instance = getattr(block, '_xmodule', None) # pylint: disable=protected-access
block.parent = parent.location
return block
@classmethod
def _set_up_module_system(cls, block):
"""
Sets up the test module system for the given block.
"""
module_system = get_test_system()
module_system.descriptor_runtime = block._runtime # pylint: disable=protected-access
block.xmodule_runtime = module_system
def test_student_view_init(self):
seq_module = SequenceModule(runtime=Mock(position=2), descriptor=Mock(), scope_ids=Mock())
self.assertEquals(seq_module.position, 2) # matches position set in the runtime
def test_render_student_view(self):
html = self._get_rendered_student_view(self.sequence_3_1, requested_child=None)
self._assert_view_at_position(html, expected_position=1)
self.assertIn(unicode(self.sequence_3_1.location), html)
self.assertIn("'next_url': u'{}'".format(unicode(self.chapter_4.location)), html)
self.assertIn("'prev_url': u'{}'".format(unicode(self.chapter_2.location)), html)
def test_student_view_first_child(self):
html = self._get_rendered_student_view(self.sequence_3_1, requested_child='first')
self._assert_view_at_position(html, expected_position=1)
def test_student_view_last_child(self):
html = self._get_rendered_student_view(self.sequence_3_1, requested_child='last')
self._assert_view_at_position(html, expected_position=3)
def _get_rendered_student_view(self, sequence, requested_child):
"""
Returns the rendered student view for the given sequence and the
requested_child parameter.
"""
return sequence.xmodule_runtime.render(
sequence,
STUDENT_VIEW,
{
'redirect_url_func': lambda course_key, block_location, child: unicode(block_location),
'requested_child': requested_child,
},
).content
def _assert_view_at_position(self, rendered_html, expected_position):
"""
Verifies that the rendered view contains the expected position.
"""
self.assertIn("'position': {}".format(expected_position), rendered_html)
def test_compute_next_url(self):
for sequence, parent, expected_next_sequence_location in [
(self.sequence_1_1, self.chapter_1, self.sequence_1_2.location),
(self.sequence_1_2, self.chapter_1, self.chapter_2.location),
(self.sequence_3_1, self.chapter_3, self.chapter_4.location),
(self.sequence_4_1, self.chapter_4, self.sequence_4_2.location),
(self.sequence_4_2, self.chapter_4, None),
]:
actual_next_sequence_location = _compute_next_url(
sequence.location,
parent,
lambda course_key, block_location, child: block_location,
)
self.assertEquals(actual_next_sequence_location, expected_next_sequence_location)
def test_compute_previous_url(self):
for sequence, parent, expected_prev_sequence_location in [
(self.sequence_1_1, self.chapter_1, None),
(self.sequence_1_2, self.chapter_1, self.sequence_1_1.location),
(self.sequence_3_1, self.chapter_3, self.chapter_2.location),
(self.sequence_4_1, self.chapter_4, self.chapter_3.location),
(self.sequence_4_2, self.chapter_4, self.sequence_4_1.location),
]:
actual_next_sequence_location = _compute_previous_url(
sequence.location,
parent,
lambda course_key, block_location, child: block_location,
)
self.assertEquals(actual_next_sequence_location, expected_prev_sequence_location)
......@@ -57,7 +57,8 @@ class InMemorySystem(XMLParsingSystem, MakoDescriptorSystem): # pylint: disable
class XModuleXmlImportTest(TestCase):
"""Base class for tests that use basic XML parsing"""
def process_xml(self, xml_import_data):
@classmethod
def process_xml(cls, xml_import_data):
"""Use the `xml_import_data` to import an :class:`XBlock` from XML."""
system = InMemorySystem(xml_import_data)
return system.process_xml(xml_import_data.xml_string)
......@@ -8,6 +8,7 @@ from fs.memoryfs import MemoryFS
from factory import Factory, lazy_attribute, post_generation, Sequence
from lxml import etree
from xblock.mixins import HierarchyMixin
from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.x_module import XModuleMixin
from xmodule.modulestore import only_xmodules
......@@ -68,7 +69,7 @@ class XmlImportFactory(Factory):
model = XmlImportData
filesystem = MemoryFS()
xblock_mixins = (InheritanceMixin, XModuleMixin)
xblock_mixins = (InheritanceMixin, XModuleMixin, HierarchyMixin)
xblock_select = only_xmodules
url_name = Sequence(str)
attribs = {}
......@@ -142,6 +143,11 @@ class CourseFactory(XmlImportFactory):
static_asset_path = 'xml_test_course'
class ChapterFactory(XmlImportFactory):
"""Factory for <chapter> nodes"""
tag = 'chapter'
class SequenceFactory(XmlImportFactory):
"""Factory for <sequential> nodes"""
tag = 'sequential'
......
......@@ -3,7 +3,7 @@ Course navigation page object
"""
import re
from bok_choy.page_object import PageObject
from bok_choy.page_object import PageObject, unguarded
from bok_choy.promise import EmptyPromise
......@@ -57,7 +57,7 @@ class CourseNavPage(PageObject):
Example return value:
['Chemical Bonds Video', 'Practice Problems', 'Homework']
"""
seq_css = 'ol#sequence-list>li>a>.sequence-tooltip'
seq_css = 'ol#sequence-list>li>.nav-item>.sequence-tooltip'
return self.q(css=seq_css).map(self._clean_seq_titles).results
def go_to_section(self, section_title, subsection_title):
......@@ -124,7 +124,7 @@ class CourseNavPage(PageObject):
# Click on the sequence item at the correct index
# Convert the list index (starts at 0) to a CSS index (starts at 1)
seq_css = "ol#sequence-list>li:nth-of-type({0})>a".format(seq_index + 1)
seq_css = "ol#sequence-list>li:nth-of-type({0})>.nav-item".format(seq_index + 1)
self.q(css=seq_css).first.click()
# Click triggers an ajax event
self.wait_for_ajax()
......@@ -165,10 +165,11 @@ class CourseNavPage(PageObject):
"""
desc = "currently at section '{0}' and subsection '{1}'".format(section_title, subsection_title)
return EmptyPromise(
lambda: self._is_on_section(section_title, subsection_title), desc
lambda: self.is_on_section(section_title, subsection_title), desc
)
def _is_on_section(self, section_title, subsection_title):
@unguarded
def is_on_section(self, section_title, subsection_title):
"""
Return a boolean indicating whether the user is on the section and subsection
with the specified titles.
......@@ -203,13 +204,9 @@ class CourseNavPage(PageObject):
"""
return self.REMOVE_SPAN_TAG_RE.search(element.get_attribute('innerHTML')).groups()[0].strip()
def go_to_sequential_position(self, sequential_position):
@property
def active_subsection_url(self):
"""
Within a section/subsection navigate to the sequential position specified by `sequential_position`.
Arguments:
sequential_position (int): position in sequential bar
return the url of the active subsection in the left nav
"""
sequential_position_css = '#tab_{0}'.format(sequential_position - 1)
self.q(css=sequential_position_css).first.click()
return self.q(css='.chapter-content-container .menu-item.active a').attrs('href')[0]
......@@ -21,6 +21,13 @@ class CoursewarePage(CoursePage):
return self.q(css='body.courseware').present
@property
def chapter_count_in_navigation(self):
"""
Returns count of chapters available on LHS navigation.
"""
return len(self.q(css='nav.course-navigation a.chapter'))
@property
def num_sections(self):
"""
Return the number of sections in the sidebar on the page
......@@ -101,11 +108,66 @@ class CoursewarePage(CoursePage):
return element.text[0]
return None
def get_active_subsection_url(self):
def go_to_sequential_position(self, sequential_position):
"""
Within a section/subsection navigate to the sequential position specified by `sequential_position`.
Arguments:
sequential_position (int): position in sequential bar
"""
sequential_position_css = '#sequence-list #tab_{0}'.format(sequential_position - 1)
self.q(css=sequential_position_css).first.click()
@property
def sequential_position(self):
"""
Returns the position of the active tab in the sequence.
"""
tab_id = self._active_sequence_tab.attrs('id')[0]
return int(tab_id.split('_')[1])
@property
def _active_sequence_tab(self): # pylint: disable=missing-docstring
return self.q(css='#sequence-list .nav-item.active')
@property
def is_next_button_enabled(self): # pylint: disable=missing-docstring
return not self.q(css='.sequence-nav > .sequence-nav-button.button-next.disabled').is_present()
@property
def is_previous_button_enabled(self): # pylint: disable=missing-docstring
return not self.q(css='.sequence-nav > .sequence-nav-button.button-previous.disabled').is_present()
def click_next_button_on_top(self): # pylint: disable=missing-docstring
self._click_navigation_button('sequence-nav', 'button-next')
def click_next_button_on_bottom(self): # pylint: disable=missing-docstring
self._click_navigation_button('sequence-bottom', 'button-next')
def click_previous_button_on_top(self): # pylint: disable=missing-docstring
self._click_navigation_button('sequence-nav', 'button-previous')
def click_previous_button_on_bottom(self): # pylint: disable=missing-docstring
self._click_navigation_button('sequence-bottom', 'button-previous')
def _click_navigation_button(self, top_or_bottom_class, next_or_previous_class):
"""
return the url of the active subsection in the left nav
Clicks the navigation button, given the respective CSS classes.
"""
return self.q(css='.chapter-content-container .menu-item.active a').attrs('href')[0]
previous_tab_id = self._active_sequence_tab.attrs('data-id')[0]
def is_at_new_tab_id():
"""
Returns whether the active tab has changed. It is defensive
against the case where the page is still being loaded.
"""
active_tab = self._active_sequence_tab
return active_tab and previous_tab_id != active_tab.attrs('data-id')[0]
self.q(
css='.{} > .sequence-nav-button.{}'.format(top_or_bottom_class, next_or_previous_class)
).first.click()
EmptyPromise(is_at_new_tab_id, "Button navigation fulfilled").fulfill()
@property
def can_start_proctored_exam(self):
......@@ -161,13 +223,6 @@ class CoursewarePage(CoursePage):
and "You have passed the entrance exam" in self.entrance_exam_message_selector.text[0]
@property
def chapter_count_in_navigation(self):
"""
Returns count of chapters available on LHS navigation.
"""
return len(self.q(css='nav.course-navigation a.chapter'))
@property
def is_timer_bar_present(self):
"""
Returns True if the timed/proctored exam timer bar is visible on the courseware.
......@@ -178,7 +233,7 @@ class CoursewarePage(CoursePage):
""" Returns the usage id of active sequence item """
get_active = lambda el: 'active' in el.get_attribute('class')
attribute_value = lambda el: el.get_attribute('data-id')
return self.q(css='#sequence-list a').filter(get_active).map(attribute_value).results[0]
return self.q(css='#sequence-list .nav-item').filter(get_active).map(attribute_value).results[0]
@property
def breadcrumb(self):
......
......@@ -2,7 +2,7 @@
"""
End-to-end tests for the LMS.
"""
import time
from nose.plugins.attrib import attr
from ..helpers import UniqueCourseTest
from ...pages.studio.auto_auth import AutoAuthPage
......@@ -420,13 +420,20 @@ class CoursewareMultipleVerticalsTest(UniqueCourseTest):
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section 1').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection 1').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection 1,1').add_children(
XBlockFixtureDesc('problem', 'Test Problem 1', data='<problem>problem 1 dummy body</problem>'),
XBlockFixtureDesc('html', 'html 1', data="<html>html 1 dummy body</html>"),
XBlockFixtureDesc('problem', 'Test Problem 2', data="<problem>problem 2 dummy body</problem>"),
XBlockFixtureDesc('html', 'html 2', data="<html>html 2 dummy body</html>"),
),
XBlockFixtureDesc('sequential', 'Test Subsection 2'),
XBlockFixtureDesc('sequential', 'Test Subsection 1,2').add_children(
XBlockFixtureDesc('problem', 'Test Problem 3', data='<problem>problem 3 dummy body</problem>'),
),
),
XBlockFixtureDesc('chapter', 'Test Section 2').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection 2,1').add_children(
XBlockFixtureDesc('problem', 'Test Problem 4', data='<problem>problem 4 dummy body</problem>'),
),
),
).install()
......@@ -436,10 +443,53 @@ class CoursewareMultipleVerticalsTest(UniqueCourseTest):
self.courseware_page.visit()
self.course_nav = CourseNavPage(self.browser)
def test_navigation_buttons(self):
# start in first section
self.assert_navigation_state('Test Section 1', 'Test Subsection 1,1', 0, next_enabled=True, prev_enabled=False)
# next takes us to next tab in sequential
self.courseware_page.click_next_button_on_top()
self.assert_navigation_state('Test Section 1', 'Test Subsection 1,1', 1, next_enabled=True, prev_enabled=True)
# go to last sequential position
self.courseware_page.go_to_sequential_position(4)
self.assert_navigation_state('Test Section 1', 'Test Subsection 1,1', 3, next_enabled=True, prev_enabled=True)
# next takes us to next sequential
self.courseware_page.click_next_button_on_bottom()
self.assert_navigation_state('Test Section 1', 'Test Subsection 1,2', 0, next_enabled=True, prev_enabled=True)
# next takes us to next chapter
self.courseware_page.click_next_button_on_top()
self.assert_navigation_state('Test Section 2', 'Test Subsection 2,1', 0, next_enabled=False, prev_enabled=True)
# previous takes us to previous chapter
self.courseware_page.click_previous_button_on_top()
self.assert_navigation_state('Test Section 1', 'Test Subsection 1,2', 0, next_enabled=True, prev_enabled=True)
# previous takes us to last tab in previous sequential
self.courseware_page.click_previous_button_on_bottom()
self.assert_navigation_state('Test Section 1', 'Test Subsection 1,1', 3, next_enabled=True, prev_enabled=True)
# previous takes us to previous tab in sequential
self.courseware_page.click_previous_button_on_bottom()
self.assert_navigation_state('Test Section 1', 'Test Subsection 1,1', 2, next_enabled=True, prev_enabled=True)
def assert_navigation_state(
self, section_title, subsection_title, subsection_position, next_enabled, prev_enabled
):
"""
Verifies that the navigation state is as expected.
"""
self.assertTrue(self.course_nav.is_on_section(section_title, subsection_title))
self.assertEquals(self.courseware_page.sequential_position, subsection_position)
self.assertEquals(self.courseware_page.is_next_button_enabled, next_enabled)
self.assertEquals(self.courseware_page.is_previous_button_enabled, prev_enabled)
def test_tab_position(self):
# test that using the position in the url direct to correct tab in courseware
self.course_nav.go_to_section('Test Section 1', 'Test Subsection 1')
subsection_url = self.courseware_page.get_active_subsection_url()
self.course_nav.go_to_section('Test Section 1', 'Test Subsection 1,1')
subsection_url = self.course_nav.active_subsection_url
url_part_list = subsection_url.split('/')
self.assertEqual(len(url_part_list), 9)
......@@ -481,3 +531,14 @@ class CoursewareMultipleVerticalsTest(UniqueCourseTest):
position=4
).visit()
self.assertIn('html 2 dummy body', html2_page.get_selected_tab_content())
@attr('a11y')
def test_courseware_a11y(self):
"""
Run accessibility audit for the problem type.
"""
self.course_nav.go_to_section('Test Section 1', 'Test Subsection 1,1')
# Set the scope to the sequence navigation
self.courseware_page.a11y_audit.config.set_scope(
include=['div.sequence-nav'])
self.courseware_page.a11y_audit.check_for_accessibility_errors()
......@@ -194,14 +194,14 @@ class EdxNotesDefaultInteractionsTest(EdxNotesTestMixin):
self.create_notes(components)
self.assert_text_in_notes(self.note_unit_page.notes)
self.course_nav.go_to_sequential_position(2)
self.courseware_page.go_to_sequential_position(2)
components = self.note_unit_page.components
self.create_notes(components)
components = self.note_unit_page.refresh()
self.assert_text_in_notes(self.note_unit_page.notes)
self.course_nav.go_to_sequential_position(1)
self.courseware_page.go_to_sequential_position(1)
components = self.note_unit_page.components
self.assert_text_in_notes(self.note_unit_page.notes)
......@@ -227,7 +227,7 @@ class EdxNotesDefaultInteractionsTest(EdxNotesTestMixin):
self.edit_notes(components)
self.assert_text_in_notes(self.note_unit_page.notes)
self.course_nav.go_to_sequential_position(2)
self.courseware_page.go_to_sequential_position(2)
components = self.note_unit_page.components
self.edit_notes(components)
self.assert_text_in_notes(self.note_unit_page.notes)
......@@ -235,7 +235,7 @@ class EdxNotesDefaultInteractionsTest(EdxNotesTestMixin):
components = self.note_unit_page.refresh()
self.assert_text_in_notes(self.note_unit_page.notes)
self.course_nav.go_to_sequential_position(1)
self.courseware_page.go_to_sequential_position(1)
components = self.note_unit_page.components
self.assert_text_in_notes(self.note_unit_page.notes)
......@@ -261,7 +261,7 @@ class EdxNotesDefaultInteractionsTest(EdxNotesTestMixin):
self.remove_notes(components)
self.assert_notes_are_removed(components)
self.course_nav.go_to_sequential_position(2)
self.courseware_page.go_to_sequential_position(2)
components = self.note_unit_page.components
self.remove_notes(components)
self.assert_notes_are_removed(components)
......@@ -269,7 +269,7 @@ class EdxNotesDefaultInteractionsTest(EdxNotesTestMixin):
components = self.note_unit_page.refresh()
self.assert_notes_are_removed(components)
self.course_nav.go_to_sequential_position(1)
self.courseware_page.go_to_sequential_position(1)
components = self.note_unit_page.components
self.assert_notes_are_removed(components)
......@@ -1106,10 +1106,10 @@ class EdxNotesPageTest(EventsTestMixin, EdxNotesTestMixin):
self.assertTrue(note.is_visible)
note = self.note_unit_page.notes[1]
self.assertFalse(note.is_visible)
self.course_nav.go_to_sequential_position(2)
self.courseware_page.go_to_sequential_position(2)
note = self.note_unit_page.notes[0]
self.assertFalse(note.is_visible)
self.course_nav.go_to_sequential_position(1)
self.courseware_page.go_to_sequential_position(1)
note = self.note_unit_page.notes[0]
self.assertFalse(note.is_visible)
......@@ -1494,7 +1494,7 @@ class EdxNotesToggleNotesTest(EdxNotesTestMixin):
# Disable all notes
self.note_unit_page.toggle_visibility()
self.assertEqual(len(self.note_unit_page.notes), 0)
self.course_nav.go_to_sequential_position(2)
self.courseware_page.go_to_sequential_position(2)
self.assertEqual(len(self.note_unit_page.notes), 0)
self.course_nav.go_to_section(u"Test Section 1", u"Test Subsection 2")
self.assertEqual(len(self.note_unit_page.notes), 0)
......@@ -1520,7 +1520,7 @@ class EdxNotesToggleNotesTest(EdxNotesTestMixin):
# the page.
self.note_unit_page.toggle_visibility()
self.assertGreater(len(self.note_unit_page.notes), 0)
self.course_nav.go_to_sequential_position(2)
self.courseware_page.go_to_sequential_position(2)
self.assertGreater(len(self.note_unit_page.notes), 0)
self.course_nav.go_to_section(u"Test Section 1", u"Test Subsection 2")
self.assertGreater(len(self.note_unit_page.notes), 0)
......@@ -1546,7 +1546,7 @@ class PublishSectionTest(CourseOutlineTest):
self.assertTrue(section.publish_action)
self.courseware.visit()
self.assertEqual(1, self.courseware.num_xblock_components)
self.course_nav.go_to_sequential_position(2)
self.courseware.go_to_sequential_position(2)
self.assertEqual(1, self.courseware.num_xblock_components)
def test_section_publishing(self):
......@@ -1571,7 +1571,7 @@ class PublishSectionTest(CourseOutlineTest):
self.assertFalse(unit.publish_action)
self.courseware.visit()
self.assertEqual(1, self.courseware.num_xblock_components)
self.course_nav.go_to_sequential_position(2)
self.courseware.go_to_sequential_position(2)
self.assertEqual(1, self.courseware.num_xblock_components)
self.course_nav.go_to_section(SECTION_NAME, 'Test Subsection 2')
self.assertEqual(1, self.courseware.num_xblock_components)
......
......@@ -11,6 +11,7 @@ from unittest import skipIf, skip
from ..helpers import UniqueCourseTest, is_youtube_available, YouTubeStubConfig
from ...pages.lms.video.video import VideoPage
from ...pages.lms.tab_nav import TabNavPage
from ...pages.lms.courseware import CoursewarePage
from ...pages.lms.course_nav import CourseNavPage
from ...pages.lms.auto_auth import AutoAuthPage
from ...pages.lms.course_info import CourseInfoPage
......@@ -49,6 +50,7 @@ class VideoBaseTest(UniqueCourseTest):
self.video = VideoPage(self.browser)
self.tab_nav = TabNavPage(self.browser)
self.course_nav = CourseNavPage(self.browser)
self.courseware = CoursewarePage(self.browser, self.course_id)
self.course_info_page = CourseInfoPage(self.browser, self.course_id)
self.auth_page = AutoAuthPage(self.browser, course_id=self.course_id)
......@@ -190,7 +192,7 @@ class VideoBaseTest(UniqueCourseTest):
"""
Navigate to sequential specified by `video_display_name`
"""
self.course_nav.go_to_sequential_position(position)
self.courseware.go_to_sequential_position(position)
self.video.wait_for_video_player_render()
......@@ -917,13 +919,13 @@ class YouTubeVideoTest(VideoBaseTest):
execute_video_steps(['A'])
# go to second sequential position
self.course_nav.go_to_sequential_position(2)
self.courseware.go_to_sequential_position(2)
execute_video_steps(tab2_video_names)
# go back to first sequential position
# we are again playing tab 1 videos to ensure that switching didn't broke some video functionality.
self.course_nav.go_to_sequential_position(1)
self.courseware.go_to_sequential_position(1)
execute_video_steps(tab1_video_names)
self.video.browser.refresh()
......
......@@ -109,7 +109,7 @@ def when_i_navigate_to_a_subsection(step):
@step(u'I navigate to an item in a sequence')
def when_i_navigate_to_an_item_in_a_sequence(step):
sequence_css = 'a[data-element="2"]'
sequence_css = '.nav-item[data-element="2"]'
world.css_click(sequence_css)
......
......@@ -123,7 +123,7 @@ class SplitTestBase(SharedModuleStoreTestCase):
content = resp.content
# Assert we see the proper icon in the top display
self.assertIn('<a class="{} inactive progress-0 nav-item"'.format(self.ICON_CLASSES[user_tag]), content)
self.assertIn('<button class="{} inactive progress-0 nav-item"'.format(self.ICON_CLASSES[user_tag]), content)
# And proper tooltips
for tooltip in self.TOOLTIPS[user_tag]:
self.assertIn(tooltip, content)
......
......@@ -37,6 +37,7 @@ from course_modes.tests.factories import CourseModeFactory
from courseware.model_data import set_score
from courseware.testutils import RenderXBlockTestMixin
from courseware.tests.factories import StudentModuleFactory
from courseware.url_helpers import get_redirect_url
from courseware.user_state_client import DjangoXBlockUserStateClient
from edxmako.tests import mako_middleware_process_request
from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error
......@@ -192,9 +193,25 @@ class ViewsTestCase(ModuleStoreTestCase):
super(ViewsTestCase, self).setUp()
self.course = CourseFactory.create(display_name=u'teꜱᴛ course')
self.chapter = ItemFactory.create(category='chapter', parent_location=self.course.location)
self.section = ItemFactory.create(category='sequential', parent_location=self.chapter.location, due=datetime(2013, 9, 18, 11, 30, 00))
self.section = ItemFactory.create(
category='sequential',
parent_location=self.chapter.location,
due=datetime(2013, 9, 18, 11, 30, 00),
)
self.vertical = ItemFactory.create(category='vertical', parent_location=self.section.location)
self.component = ItemFactory.create(category='problem', parent_location=self.vertical.location)
self.component = ItemFactory.create(
category='problem',
parent_location=self.vertical.location,
display_name='Problem 1',
)
self.section2 = ItemFactory.create(category='sequential', parent_location=self.chapter.location)
self.vertical2 = ItemFactory.create(category='vertical', parent_location=self.section2.location)
ItemFactory.create(
category='problem',
parent_location=self.vertical2.location,
display_name='Problem 2',
)
self.course_key = self.course.id
self.password = '123456'
......@@ -210,6 +227,74 @@ class ViewsTestCase(ModuleStoreTestCase):
self.org = u"ꜱᴛᴀʀᴋ ɪɴᴅᴜꜱᴛʀɪᴇꜱ"
self.org_html = "<p>'+Stark/Industries+'</p>"
def test_index_success(self):
response = self._verify_index_response()
self.assertIn('Problem 2', response.content)
# re-access to the main course page redirects to last accessed view.
url = reverse('courseware', kwargs={'course_id': unicode(self.course_key)})
response = self.client.get(url)
self.assertEqual(response.status_code, 302)
response = self.client.get(response.url) # pylint: disable=no-member
self.assertNotIn('Problem 1', response.content)
self.assertIn('Problem 2', response.content)
def test_index_nonexistent_chapter(self):
self._verify_index_response(expected_response_code=404, chapter_name='non-existent')
def test_index_nonexistent_chapter_masquerade(self):
with patch('courseware.views.setup_masquerade') as patch_masquerade:
masquerade = MagicMock(role='student')
patch_masquerade.return_value = (masquerade, self.user)
self._verify_index_response(expected_response_code=302, chapter_name='non-existent')
def test_index_nonexistent_section(self):
self._verify_index_response(expected_response_code=404, section_name='non-existent')
def test_index_nonexistent_section_masquerade(self):
with patch('courseware.views.setup_masquerade') as patch_masquerade:
masquerade = MagicMock(role='student')
patch_masquerade.return_value = (masquerade, self.user)
self._verify_index_response(expected_response_code=302, section_name='non-existent')
def _verify_index_response(self, expected_response_code=200, chapter_name=None, section_name=None):
"""
Verifies the response when the courseware index page is accessed with
the given chapter and section names.
"""
self.client.login(username=self.user.username, password=self.password)
url = reverse(
'courseware_section',
kwargs={
'course_id': unicode(self.course_key),
'chapter': unicode(self.chapter.location.name) if chapter_name is None else chapter_name,
'section': unicode(self.section2.location.name) if section_name is None else section_name,
}
)
response = self.client.get(url)
self.assertEqual(response.status_code, expected_response_code)
return response
def test_index_no_visible_section_in_chapter(self):
self.client.login(username=self.user.username, password=self.password)
# reload the chapter from the store so its children information is updated
self.chapter = self.store.get_item(self.chapter.location)
# disable the visibility of the sections in the chapter
for section in self.chapter.get_children():
section.visible_to_staff_only = True
self.store.update_item(section, ModuleStoreEnum.UserID.test)
url = reverse(
'courseware_chapter',
kwargs={'course_id': unicode(self.course.id), 'chapter': unicode(self.chapter.location.name)},
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertNotIn('Problem 1', response.content)
self.assertNotIn('Problem 2', response.content)
@unittest.skipUnless(settings.FEATURES.get('ENABLE_SHOPPING_CART'), "Shopping Cart not enabled in settings")
@patch.dict(settings.FEATURES, {'ENABLE_PAID_COURSE_REGISTRATION': True})
def test_course_about_in_cart(self):
......@@ -297,15 +382,37 @@ class ViewsTestCase(ModuleStoreTestCase):
self.assertEqual(views.user_groups(mock_user), [])
def test_get_current_child(self):
self.assertIsNone(views.get_current_child(MagicMock()))
mock_xmodule = MagicMock()
self.assertIsNone(views.get_current_child(mock_xmodule))
mock_xmodule.position = -1
mock_xmodule.get_display_items.return_value = ['one', 'two']
mock_xmodule.get_display_items.return_value = ['one', 'two', 'three']
self.assertEqual(views.get_current_child(mock_xmodule), 'one')
mock_xmodule_2 = MagicMock()
mock_xmodule_2.position = 3
mock_xmodule_2.get_display_items.return_value = []
self.assertIsNone(views.get_current_child(mock_xmodule_2))
mock_xmodule.position = 2
self.assertEqual(views.get_current_child(mock_xmodule), 'two')
self.assertEqual(views.get_current_child(mock_xmodule, requested_child='first'), 'one')
self.assertEqual(views.get_current_child(mock_xmodule, requested_child='last'), 'three')
mock_xmodule.position = 3
mock_xmodule.get_display_items.return_value = []
self.assertIsNone(views.get_current_child(mock_xmodule))
def test_get_redirect_url(self):
self.assertIn(
'activate_block_id',
get_redirect_url(self.course_key, self.section.location),
)
self.assertIn(
'child=first',
get_redirect_url(self.course_key, self.section.location, child='first'),
)
self.assertIn(
'child=last',
get_redirect_url(self.course_key, self.section.location, child='last'),
)
def test_redirect_to_course_position(self):
mock_module = MagicMock()
......
......@@ -7,12 +7,13 @@ from xmodule.modulestore.django import modulestore
from django.core.urlresolvers import reverse
def get_redirect_url(course_key, usage_key):
def get_redirect_url(course_key, usage_key, child=None):
""" Returns the redirect url back to courseware
Args:
course_id(str): Course Id string
location(str): The location id of course component
child(str): Optional child parameter to pass to the URL
Raises:
ItemNotFoundError if no data at the location or NoPathToItem if location not in any class
......@@ -50,4 +51,7 @@ def get_redirect_url(course_key, usage_key):
redirect_url += "?{}".format(urlencode({'activate_block_id': unicode(final_target_id)}))
if child:
redirect_url += "&child={}".format(child)
return redirect_url
......@@ -162,6 +162,9 @@ FEATURES['ENABLE_COURSEWARE_SEARCH'] = True
# Enable dashboard search for tests
FEATURES['ENABLE_DASHBOARD_SEARCH'] = True
# Enable cross-section Next button for tests
FEATURES['ENABLE_NEXT_BUTTON_ACROSS_SECTIONS'] = True
# Use MockSearchEngine as the search engine for test scenario
SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
# Path at which to store the mock index
......
......@@ -371,7 +371,10 @@ FEATURES = {
# This is the default, but can be disabled if all history
# lives in the Extended table, saving the frontend from
# making multiple queries.
'ENABLE_READING_FROM_MULTIPLE_HISTORY_TABLES': True
'ENABLE_READING_FROM_MULTIPLE_HISTORY_TABLES': True,
# Enable Next Button to jump sequences in Sequence Navigation bar.
'ENABLE_NEXT_BUTTON_ACROSS_SECTIONS': False,
}
# Ignore static asset files on import which match this pattern
......
<%page expression_filter="h"/>
<%! from django.utils.translation import ugettext as _ %>
<div id="sequence_${element_id}" class="sequence" data-id="${item_id}" data-position="${position}" data-ajax-url="${ajax_url}" >
<div id="sequence_${element_id}" class="sequence" data-id="${item_id}" data-position="${position}" data-ajax-url="${ajax_url}" data-next-url="${next_url}" data-prev-url="${prev_url}">
<div class="path"></div>
<div class="sequence-nav">
<button class="sequence-nav-button button-previous">
......@@ -14,19 +15,16 @@
## implementation note: will need to figure out how to handle combining detail
## statuses of multiple modules in js.
<li>
<a class="seq_${item['type']} inactive progress-${item['progress_status']} nav-item"
<button class="seq_${item['type']} inactive progress-${item['progress_status']} nav-item"
data-id="${item['id']}"
data-element="${idx+1}"
href="javascript:void(0);"
data-page-title="${item['page_title']|h}"
data-path="${item['path']|h}"
aria-controls="seq_contents_${idx}"
id="tab_${idx}"
tabindex="0">
data-page-title="${item['page_title']}"
data-path="${item['path']}"
id="tab_${idx}">
<i class="icon fa seq_${item['type']}" aria-hidden="true"></i>
<i class="fa fa-fw fa-bookmark bookmark-icon ${"is-hidden" if not item['bookmarked'] else "bookmarked"}" aria-hidden="true"></i>
<div class="sequence-tooltip"><span class="sr">${item['type']}&nbsp;</span>${item['title']}<span class="sr bookmark-icon-sr">&nbsp;${_("Bookmarked") if item['bookmarked'] else ""}</span></div>
</a>
<div class="sequence-tooltip sr"><span class="sr">${item['type']}&nbsp;</span>${item['title']}<span class="sr bookmark-icon-sr">&nbsp;${_("Bookmarked") if item['bookmarked'] else ""}</span></div>
</button>
</li>
% endfor
</ol>
......@@ -43,7 +41,7 @@
aria-labelledby="tab_${idx}"
aria-hidden="true"
class="seq_contents tex2jax_ignore asciimath2jax_ignore">
${item['content'] | h}
${item['content']}
</div>
% endfor
<div id="seq_content"></div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment