Commit 1b3a3a23 by Brian Jacobel Committed by GitHub

Merge pull request #14637 from edx/bjacobel/resume-outline

Add resume indicator to course outline
parents eb34d3fe 22203453
...@@ -16,6 +16,8 @@ class CourseHomePage(CoursePage): ...@@ -16,6 +16,8 @@ class CourseHomePage(CoursePage):
url_path = "course/" url_path = "course/"
HEADER_RESUME_COURSE_SELECTOR = '.page-header .action-resume-course'
def is_browser_on_page(self): def is_browser_on_page(self):
return self.q(css='.course-outline').present return self.q(css='.course-outline').present
...@@ -32,6 +34,14 @@ class CourseHomePage(CoursePage): ...@@ -32,6 +34,14 @@ class CourseHomePage(CoursePage):
bookmarks_page = BookmarksPage(self.browser, self.course_id) bookmarks_page = BookmarksPage(self.browser, self.course_id)
bookmarks_page.visit() bookmarks_page.visit()
def resume_course_from_header(self):
"""
Navigate to courseware using Resume Course button in the header.
"""
self.q(css=self.HEADER_RESUME_COURSE_SELECTOR).first.click()
courseware_page = CoursewarePage(self.browser, self.course_id)
courseware_page.wait_for_page()
class CourseOutlinePage(PageObject): class CourseOutlinePage(PageObject):
""" """
...@@ -40,10 +50,15 @@ class CourseOutlinePage(PageObject): ...@@ -40,10 +50,15 @@ class CourseOutlinePage(PageObject):
url = None url = None
SECTION_SELECTOR = '.outline-item.section:nth-of-type({0})'
SECTION_TITLES_SELECTOR = '.section-name span'
SUBSECTION_SELECTOR = SECTION_SELECTOR + ' .subsection:nth-of-type({1}) .outline-item'
SUBSECTION_TITLES_SELECTOR = SECTION_SELECTOR + ' .subsection a span:first-child'
OUTLINE_RESUME_COURSE_SELECTOR = '.outline-item .resume-right'
def __init__(self, browser, parent_page): def __init__(self, browser, parent_page):
super(CourseOutlinePage, self).__init__(browser) super(CourseOutlinePage, self).__init__(browser)
self.parent_page = parent_page self.parent_page = parent_page
self.courseware_page = CoursewarePage(self.browser, self.parent_page.course_id)
def is_browser_on_page(self): def is_browser_on_page(self):
return self.parent_page.is_browser_on_page return self.parent_page.is_browser_on_page
...@@ -105,43 +120,34 @@ class CourseOutlinePage(PageObject): ...@@ -105,43 +120,34 @@ class CourseOutlinePage(PageObject):
return return
# Convert list indices (start at zero) to CSS indices (start at 1) # Convert list indices (start at zero) to CSS indices (start at 1)
subsection_css = ( subsection_css = self.SUBSECTION_SELECTOR.format(section_index + 1, subsection_index + 1)
".outline-item.section:nth-of-type({0}) .subsection:nth-of-type({1}) .outline-item"
).format(section_index + 1, subsection_index + 1)
# Click the subsection and ensure that the page finishes reloading # Click the subsection and ensure that the page finishes reloading
self.q(css=subsection_css).first.click() self.q(css=subsection_css).first.click()
self.courseware_page.wait_for_page()
# TODO: TNL-6546: Remove this if/visit_unified_course_view
if self.parent_page.unified_course_view:
self.courseware_page.nav.visit_unified_course_view()
self._wait_for_course_section(section_title, subsection_title) self._wait_for_course_section(section_title, subsection_title)
def resume_course_from_outline(self):
"""
Navigate to courseware using Resume Course button in the header.
"""
self.q(css=self.OUTLINE_RESUME_COURSE_SELECTOR).first.click()
courseware_page = CoursewarePage(self.browser, self.parent_page.course_id)
courseware_page.wait_for_page()
def _section_titles(self): def _section_titles(self):
""" """
Return a list of all section titles on the page. Return a list of all section titles on the page.
""" """
section_css = '.section-name span' return self.q(css=self.SECTION_TITLES_SELECTOR).map(lambda el: el.text.strip()).results
return self.q(css=section_css).map(lambda el: el.text.strip()).results
def _subsection_titles(self, section_index): def _subsection_titles(self, section_index):
""" """
Return a list of all subsection titles on the page Return a list of all subsection titles on the page
for the section at index `section_index` (starts at 1). for the section at index `section_index` (starts at 1).
""" """
# Retrieve the subsection title for the section subsection_css = self.SUBSECTION_TITLES_SELECTOR.format(section_index)
# Add one to the list index to get the CSS index, which starts at one return self.q(css=subsection_css).map(
subsection_css = (
# TODO: TNL-6387: Will need to switch to this selector for subsections
# ".outline-item.section:nth-of-type({0}) .subsection span:nth-of-type(1)"
".outline-item.section:nth-of-type({0}) .subsection a"
).format(section_index)
return self.q(
css=subsection_css
).map(
lambda el: el.get_attribute('innerHTML').strip() lambda el: el.get_attribute('innerHTML').strip()
).results ).results
...@@ -149,7 +155,14 @@ class CourseOutlinePage(PageObject): ...@@ -149,7 +155,14 @@ class CourseOutlinePage(PageObject):
""" """
Ensures the user navigates to the course content page with the correct section and subsection. Ensures the user navigates to the course content page with the correct section and subsection.
""" """
courseware_page = CoursewarePage(self.browser, self.parent_page.course_id)
courseware_page.wait_for_page()
# TODO: TNL-6546: Remove this if/visit_unified_course_view
if self.parent_page.unified_course_view:
courseware_page.nav.visit_unified_course_view()
self.wait_for( self.wait_for(
promise_check_func=lambda: self.courseware_page.nav.is_on_section(section_title, subsection_title), promise_check_func=lambda: courseware_page.nav.is_on_section(section_title, subsection_title),
description="Waiting for course page with section '{0}' and subsection '{1}'".format(section_title, subsection_title) description="Waiting for course page with section '{0}' and subsection '{1}'".format(section_title, subsection_title)
) )
...@@ -860,6 +860,16 @@ class HighLevelTabTest(UniqueCourseTest): ...@@ -860,6 +860,16 @@ class HighLevelTabTest(UniqueCourseTest):
bookmarks_page = BookmarksPage(self.browser, self.course_id) bookmarks_page = BookmarksPage(self.browser, self.course_id)
self.assertTrue(bookmarks_page.is_browser_on_page()) self.assertTrue(bookmarks_page.is_browser_on_page())
# Test "Resume Course" button from header
self.course_home_page.visit()
self.course_home_page.resume_course_from_header()
self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3'))
# Test "Resume Course" button from within outline
self.course_home_page.visit()
self.course_home_page.outline.resume_course_from_outline()
self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3'))
@attr('a11y') @attr('a11y')
def test_course_home_a11y(self): def test_course_home_a11y(self):
self.course_home_page.visit() self.course_home_page.visit()
......
...@@ -392,7 +392,7 @@ def course_info(request, course_id): ...@@ -392,7 +392,7 @@ def course_info(request, course_id):
# Get the URL of the user's last position in order to display the 'where you were last' message # Get the URL of the user's last position in order to display the 'where you were last' message
context['last_accessed_courseware_url'] = None context['last_accessed_courseware_url'] = None
if SelfPacedConfiguration.current().enable_course_home_improvements: if SelfPacedConfiguration.current().enable_course_home_improvements:
context['last_accessed_courseware_url'] = get_last_accessed_courseware(course, request, user) context['last_accessed_courseware_url'], _ = get_last_accessed_courseware(course, request, user)
now = datetime.now(UTC()) now = datetime.now(UTC())
effective_start = _adjust_start_date_for_beta_testers(user, course, course_key) effective_start = _adjust_start_date_for_beta_testers(user, course, course_key)
...@@ -427,8 +427,8 @@ def course_info(request, course_id): ...@@ -427,8 +427,8 @@ def course_info(request, course_id):
def get_last_accessed_courseware(course, request, user): def get_last_accessed_courseware(course, request, user):
""" """
Return the courseware module URL that the user last accessed, Returns a tuple containing the courseware module (URL, id) that the user last accessed,
or None if it cannot be found. or (None, None) if it cannot be found.
""" """
field_data_cache = FieldDataCache.cache_for_descriptor_descendents( field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course.id, request.user, course, depth=2 course.id, request.user, course, depth=2
...@@ -445,8 +445,8 @@ def get_last_accessed_courseware(course, request, user): ...@@ -445,8 +445,8 @@ def get_last_accessed_courseware(course, request, user):
'chapter': chapter_module.url_name, 'chapter': chapter_module.url_name,
'section': section_module.url_name 'section': section_module.url_name
}) })
return url return (url, section_module.url_name)
return None return (None, None)
class StaticCourseTabView(FragmentView): class StaticCourseTabView(FragmentView):
......
...@@ -43,6 +43,14 @@ ...@@ -43,6 +43,14 @@
text-decoration: none; text-decoration: none;
} }
} }
&.current {
border: 1px solid $lms-active-color;
.resume-right {
@include float(right);
}
}
} }
} }
} }
......
...@@ -27,13 +27,25 @@ from django.utils.translation import ugettext as _ ...@@ -27,13 +27,25 @@ from django.utils.translation import ugettext as _
</div> </div>
<ol class="outline-item focusable" role="group" tabindex="0"> <ol class="outline-item focusable" role="group" tabindex="0">
% for subsection in section.get('children') or []: % for subsection in section.get('children') or []:
<li class="subsection" role="treeitem" tabindex="-1" aria-expanded="true"> <li
class="subsection ${ 'current' if subsection['current'] else '' }"
role="treeitem"
tabindex="-1"
aria-expanded="true"
>
<a <a
class="outline-item focusable" class="outline-item focusable"
href="${ subsection['lms_web_url'] }" href="${ subsection['lms_web_url'] }"
id="${ subsection['id'] }" id="${ subsection['id'] }"
> >
${ subsection['display_name'] } <span>${ subsection['display_name'] }</span>
<span class="sr-only">${ _("This is your last visited course section.") }</span>
% if subsection['current']:
<span class="resume-right">
<b>${ _("Resume Course") }</b>
<span class="icon fa fa-arrow-circle-right" aria-hidden="true"></span>
</span>
%endif
</a> </a>
</li> </li>
% endfor % endfor
......
""" """
Tests for the Course Outline view and supporting views. Tests for the Course Outline view and supporting views.
""" """
from mock import patch
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -26,6 +27,7 @@ class TestCourseOutlinePage(SharedModuleStoreTestCase): ...@@ -26,6 +27,7 @@ class TestCourseOutlinePage(SharedModuleStoreTestCase):
chapter = ItemFactory.create(category='chapter', parent_location=course.location) chapter = ItemFactory.create(category='chapter', parent_location=course.location)
section = ItemFactory.create(category='sequential', parent_location=chapter.location) section = ItemFactory.create(category='sequential', parent_location=chapter.location)
ItemFactory.create(category='vertical', parent_location=section.location) ItemFactory.create(category='vertical', parent_location=section.location)
course.last_accessed = section.url_name
cls.courses.append(course) cls.courses.append(course)
...@@ -36,6 +38,7 @@ class TestCourseOutlinePage(SharedModuleStoreTestCase): ...@@ -36,6 +38,7 @@ class TestCourseOutlinePage(SharedModuleStoreTestCase):
section2 = ItemFactory.create(category='sequential', parent_location=chapter.location) section2 = ItemFactory.create(category='sequential', parent_location=chapter.location)
ItemFactory.create(category='vertical', parent_location=section.location) ItemFactory.create(category='vertical', parent_location=section.location)
ItemFactory.create(category='vertical', parent_location=section2.location) ItemFactory.create(category='vertical', parent_location=section2.location)
course.last_accessed = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
...@@ -52,8 +55,10 @@ class TestCourseOutlinePage(SharedModuleStoreTestCase): ...@@ -52,8 +55,10 @@ class TestCourseOutlinePage(SharedModuleStoreTestCase):
super(TestCourseOutlinePage, self).setUp() super(TestCourseOutlinePage, self).setUp()
self.client.login(username=self.user.username, password=self.password) self.client.login(username=self.user.username, password=self.password)
def test_render(self): @patch('openedx.features.course_experience.views.course_outline.get_last_accessed_courseware')
def test_render(self, patched_get_last_accessed):
for course in self.courses: for course in self.courses:
patched_get_last_accessed.return_value = (None, course.last_accessed)
url = reverse( url = reverse(
'edx.course_experience.course_home', 'edx.course_experience.course_home',
kwargs={ kwargs={
...@@ -64,6 +69,10 @@ class TestCourseOutlinePage(SharedModuleStoreTestCase): ...@@ -64,6 +69,10 @@ class TestCourseOutlinePage(SharedModuleStoreTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response_content = response.content.decode("utf-8") response_content = response.content.decode("utf-8")
if course.last_accessed is not None:
self.assertIn('Resume Course', response_content)
else:
self.assertNotIn('Resume Course', response_content)
for chapter in course.children: for chapter in course.children:
self.assertIn(chapter.display_name, response_content) self.assertIn(chapter.display_name, response_content)
for section in chapter.children: for section in chapter.children:
......
...@@ -6,6 +6,7 @@ from django.core.context_processors import csrf ...@@ -6,6 +6,7 @@ from django.core.context_processors import csrf
from django.template.loader import render_to_string from django.template.loader import render_to_string
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
from lms.djangoapps.courseware.views.views import get_last_accessed_courseware
from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.course_api.blocks.api import get_blocks
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from web_fragments.fragment import Fragment from web_fragments.fragment import Fragment
...@@ -18,7 +19,7 @@ class CourseOutlineFragmentView(FragmentView): ...@@ -18,7 +19,7 @@ class CourseOutlineFragmentView(FragmentView):
Course outline fragment to be shown in the unified course view. Course outline fragment to be shown in the unified course view.
""" """
def populate_children(self, block, all_blocks): def populate_children(self, block, all_blocks, course_position):
""" """
For a passed block, replace each id in its children array with the full representation of that child, For a passed block, replace each id in its children array with the full representation of that child,
which will be looked up by id in the passed all_blocks dict. which will be looked up by id in the passed all_blocks dict.
...@@ -28,8 +29,9 @@ class CourseOutlineFragmentView(FragmentView): ...@@ -28,8 +29,9 @@ class CourseOutlineFragmentView(FragmentView):
for i in range(len(children)): for i in range(len(children)):
child_id = block['children'][i] child_id = block['children'][i]
child_detail = self.populate_children(all_blocks[child_id], all_blocks) child_detail = self.populate_children(all_blocks[child_id], all_blocks, course_position)
block['children'][i] = child_detail block['children'][i] = child_detail
block['children'][i]['current'] = course_position == child_detail['block_id']
return block return block
...@@ -39,6 +41,7 @@ class CourseOutlineFragmentView(FragmentView): ...@@ -39,6 +41,7 @@ class CourseOutlineFragmentView(FragmentView):
""" """
course_key = CourseKey.from_string(course_id) course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
_, course_position = get_last_accessed_courseware(course, request, request.user)
course_usage_key = modulestore().make_course_usage_key(course_key) course_usage_key = modulestore().make_course_usage_key(course_key)
all_blocks = get_blocks( all_blocks = get_blocks(
request, request,
...@@ -55,7 +58,7 @@ class CourseOutlineFragmentView(FragmentView): ...@@ -55,7 +58,7 @@ class CourseOutlineFragmentView(FragmentView):
'csrf': csrf(request)['csrf_token'], 'csrf': csrf(request)['csrf_token'],
'course': course, 'course': course,
# Recurse through the block tree, fleshing out each child object # Recurse through the block tree, fleshing out each child object
'blocks': self.populate_children(course_block_tree, all_blocks['blocks']) 'blocks': self.populate_children(course_block_tree, all_blocks['blocks'], course_position)
} }
html = render_to_string('course_experience/course-outline-fragment.html', context) html = render_to_string('course_experience/course-outline-fragment.html', context)
return Fragment(html) return Fragment(html)
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