Merge pull request #17902 from edx/rm-new-course-outline-waffle

EDUCATOR 2283 - Removes the new course outline waffle flag.
This commit is contained in:
Simon Chen
2018-04-14 15:07:00 -04:00
committed by GitHub
13 changed files with 389 additions and 717 deletions

View File

@@ -5,6 +5,8 @@ LMS Course Home page object
from collections import OrderedDict
from bok_choy.page_object import PageObject
from bok_choy.promise import BrokenPromise
from six import text_type
from .bookmarks import BookmarksPage
from .course_page import CoursePage
@@ -80,15 +82,11 @@ class CourseOutlinePage(PageObject):
url = None
SECTION_SELECTOR = '.outline-item.section:nth-of-type({0})'
SECTION_TITLES_SELECTOR = '.section-name h3'
SUBSECTION_SELECTOR = SECTION_SELECTOR + ' .subsection:nth-of-type({1}) .outline-item'
SUBSECTION_TITLES_SELECTOR = SECTION_SELECTOR + ' .subsection .subsection-title .subsection-title-name'
OUTLINE_RESUME_COURSE_SELECTOR = '.outline-item .resume-right'
def __init__(self, browser, parent_page):
super(CourseOutlinePage, self).__init__(browser)
self.parent_page = parent_page
self._section_selector = '.outline-item.section'
self._subsection_selector = '.subsection.accordion'
def is_browser_on_page(self):
return self.parent_page.is_browser_on_page
@@ -108,28 +106,14 @@ class CourseOutlinePage(PageObject):
You can use these titles in `go_to_section` to navigate to the section.
"""
# Dict to store the result
outline_dict = OrderedDict()
section_titles = self._section_titles()
# Get the section titles for each chapter
for sec_index, sec_title in enumerate(section_titles):
if len(section_titles) < 1:
raise ValueError("Could not find subsections for '{0}'".format(sec_title))
else:
# Add one to convert list index (starts at 0) to CSS index (starts at 1)
outline_dict[sec_title] = self._subsection_titles(sec_index + 1)
return outline_dict
return self._get_outline_structure_as_dictionary()
@property
def num_sections(self):
"""
Return the number of sections
"""
return len(self.q(css=self.SECTION_TITLES_SELECTOR))
return len(self._get_sections_as_selenium_webelements())
@property
def num_subsections(self, section_title=None):
@@ -145,14 +129,19 @@ class CourseOutlinePage(PageObject):
if not section_index:
return
else:
section_index = 1
section_index = 0
return len(self.q(css=self.SUBSECTION_TITLES_SELECTOR.format(section_index)))
sections = self._get_sections_as_selenium_webelements()
subsections = self._get_subsections(sections[section_index])
return len(subsections)
@property
def num_units(self):
"""
Return the number of units in the first subsection
Return the number of units in the first subsection.
This method returns the number of units in the horizontal navigation
bar; not the course outline.
"""
return len(self.q(css='.sequence-list-wrapper ol li'))
@@ -165,22 +154,23 @@ class CourseOutlinePage(PageObject):
Example:
go_to_section("Week 1", "Lesson 1")
"""
section_index = self._section_title_to_index(section_title)
if section_index is None:
raise ValueError("Could not find section '{0}'".format(section_title))
subsection_webelements = self._get_subsections_as_selenium_webelements()
subsection_titles = [self._get_outline_element_title(sub_webel)
for sub_webel in subsection_webelements]
try:
subsection_index = self._subsection_titles(section_index + 1).index(subsection_title)
subsection_index = subsection_titles.index(text_type(subsection_title))
except ValueError:
raise ValueError("Could not find subsection '{0}' in section '{1}'".format(
subsection_title, section_title
))
# Convert list indices (start at zero) to CSS indices (start at 1)
subsection_css = self.SUBSECTION_SELECTOR.format(section_index + 1, subsection_index + 1)
target_subsection = subsection_webelements[subsection_index]
units = self._get_units(target_subsection)
# Click the subsection and ensure that the page finishes reloading
self.q(css=subsection_css).first.click()
# Click the subsection's first problem and ensure that the page finishes
# reloading
units[0].click()
self._wait_for_course_section(section_title, subsection_title)
@@ -200,7 +190,7 @@ class CourseOutlinePage(PageObject):
except IndexError:
raise ValueError("Section index '{0}' is out of range.".format(section_index))
try:
subsection_title = self._subsection_titles(section_index + 1)[subsection_index]
subsection_title = self._subsection_titles(section_index)[subsection_index]
except IndexError:
raise ValueError("Subsection index '{0}' in section index '{1}' is out of range.".format(
subsection_index, section_index
@@ -223,7 +213,7 @@ class CourseOutlinePage(PageObject):
"""
Navigate to courseware using Resume Course button in the header.
"""
self.q(css=self.OUTLINE_RESUME_COURSE_SELECTOR).first.click()
self.q(css='.btn.btn-primary.action-resume-course').results[0].click()
courseware_page = CoursewarePage(self.browser, self.parent_page.course_id)
courseware_page.wait_for_page()
@@ -231,21 +221,26 @@ class CourseOutlinePage(PageObject):
"""
Return a list of all section titles on the page.
"""
return self.q(css=self.SECTION_TITLES_SELECTOR).map(lambda el: el.text.strip()).results
outline_sections = self._get_sections_as_selenium_webelements()
section_titles = [self._get_outline_element_title(section) for section in outline_sections]
return section_titles
def _subsection_titles(self, section_index):
"""
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 0).
"""
subsection_css = self.SUBSECTION_TITLES_SELECTOR.format(section_index)
return self.q(css=subsection_css).map(
lambda el: el.get_attribute('innerHTML').strip()
).results
outline_sections = self._get_sections_as_selenium_webelements()
target_section = outline_sections[section_index]
target_subsections = self._get_subsections(target_section)
subsection_titles = [self._get_outline_element_title(subsection)
for subsection in target_subsections]
return subsection_titles
def _wait_for_course_section(self, section_title, subsection_title):
"""
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()
@@ -255,10 +250,78 @@ class CourseOutlinePage(PageObject):
courseware_page.nav.visit_course_outline_page()
self.wait_for(
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)
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)
)
def _get_outline_structure_as_dictionary(self):
'''
Implements self.sections().
'''
outline_dict = OrderedDict()
try:
outline_sections = self._get_sections_as_selenium_webelements()
except BrokenPromise:
outline_sections = []
for section in outline_sections:
subsections = self._get_subsections(section)
section_title = self._get_outline_element_title(section)
subsection_titles = [self._get_outline_element_title(subsection)
for subsection in subsections]
outline_dict[section_title] = subsection_titles
return outline_dict
@staticmethod
def _is_html_element_aria_expanded(html_element):
return html_element.get_attribute('aria-expanded') == u'true'
@staticmethod
def _get_outline_element_title(outline_element):
return outline_element.text.split('\n')[0]
def _get_subsections(self, section):
self._expand_all_outline_folds()
return section.find_elements_by_css_selector(self._subsection_selector)
def _get_units(self, subsection):
self._expand_all_outline_folds()
return subsection.find_elements_by_tag_name('a')
def _get_sections_as_selenium_webelements(self):
self._expand_all_outline_folds()
return self.q(css=self._section_selector).results
def _get_subsections_as_selenium_webelements(self):
self._expand_all_outline_folds()
return self.q(css=self._subsection_selector).results
def _expand_all_outline_folds(self):
'''
Expands all parts of the collapsible outline.
'''
section_button_selector = '.section-name.accordion-trigger'
subsection_button_selector = '.subsection-text.accordion-trigger'
self._expand_outline_fold(section_button_selector)
self._expand_outline_fold(subsection_button_selector)
def _expand_outline_fold(self, fold_selector):
'''
Ensures an outline fold is loaded, then clicks it open.
'''
folds_as_elements = self.q(css=fold_selector)
self.wait_for_element_visibility(
fold_selector, "'{}' is visible".format(fold_selector)
)
for fold_element in folds_as_elements:
if not self._is_html_element_aria_expanded(fold_element):
fold_element.click()
class CourseSearchResultsPage(CoursePage):
"""

View File

@@ -91,8 +91,8 @@ class CourseHomeTest(CourseHomeBaseTest):
# Check that the course navigation appears correctly
EXPECTED_SECTIONS = {
'Test Section': ['Test Subsection'],
'Test Section 2': ['Test Subsection 2', 'Test Subsection 3']
u'Test Section': [u'Test Subsection'],
u'Test Section 2': [u'Test Subsection 2', u'Test Subsection 3']
}
actual_sections = self.course_home_page.outline.sections
@@ -101,7 +101,7 @@ class CourseHomeTest(CourseHomeBaseTest):
self.assertEqual(actual_sections[section], EXPECTED_SECTIONS[section])
# Navigate to a particular section
self.course_home_page.outline.go_to_section('Test Section', 'Test Subsection')
self.course_home_page.outline.go_to_section(u'Test Section', u'Test Subsection')
# Check the sequence items on the courseware page
EXPECTED_ITEMS = ['Test Problem 1', 'Test Problem 2', 'Test HTML']

View File

@@ -639,24 +639,6 @@ class CoursewareMultipleVerticalsTest(CoursewareMultipleVerticalsTestBase):
self.assertIn('html 2 dummy body', html2_page.get_selected_tab_content())
@attr('a11y')
class CoursewareMultipleVerticalsA11YTest(CoursewareMultipleVerticalsTestBase):
"""
Test a11y for courseware with multiple verticals
"""
def test_courseware_a11y(self):
"""
Run accessibility audit for the problem type.
"""
self.course_home_page.visit()
self.course_home_page.outline.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()
@attr(shard=9)
class ProblemStateOnNavigationTest(UniqueCourseTest):
"""

View File

@@ -182,17 +182,10 @@ class GatingTest(UniqueCourseTest):
Then I can see a gated subsection
The gated subsection should have a lock icon
and be in the format: "<Subsection Title> (Prerequisite Required)"
When I fufill the gating prerequisite
Then I can see the gated subsection (without a banner)
"""
self._setup_prereq()
self._setup_gated_subsection()
# Fulfill prerequisites for specific student
self._auto_auth(self.STUDENT_USERNAME, self.STUDENT_EMAIL, False)
self.courseware_page.visit()
self._fulfill_prerequisite()
self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
self.course_home_page.visit()
@@ -216,11 +209,3 @@ class GatingTest(UniqueCourseTest):
self.courseware_page.wait_for_page()
# banner displayed informing section is a prereq
self.assertTrue(self.courseware_page.has_banner())
self.course_home_page.visit()
self.course_home_page.preview.set_staff_view_mode_specific_student(self.STUDENT_USERNAME)
self.course_home_page.wait_for_page()
self.assertEqual(self.course_home_page.outline.num_subsections, 2)
self.course_home_page.outline.go_to_section('Test Section 1', 'Test Subsection 2')
self.courseware_page.wait_for_page()
self.assertFalse(self.courseware_page.has_banner())

View File

@@ -302,7 +302,7 @@
}
// Course outline for visual progress waffle switch
.course-outline-visualprogress {
.course-outline {
.block-tree {
margin: 0;
padding: 0;
@@ -451,111 +451,6 @@ button.accordion-trigger, button.prerequisite-button {
float: right;
}
// Course outline
.course-outline {
.block-tree {
margin: 0;
padding: 0;
list-style-type: none;
.section {
margin: 0 (-1 * $baseline);
width: calc(100% + (2 * $baseline));
padding: 0 ($baseline);
&:not(:first-child) {
.section-name {
margin-top: $baseline;
}
}
.section-name {
@include margin(0, 0, ($baseline / 2), ($baseline / 2));
padding: 0;
.section-title {
font-weight: $font-bold;
font-size: 1.1rem;
margin: 0;
}
}
.outline-item {
@include padding-left(0);
}
ol.outline-item {
padding-bottom: $baseline;
border-bottom: 1px solid $border-color;
margin: 0 0 ($baseline / 2) 0;
.subsection {
@include margin-left(10px);
list-style-type: none;
border: 1px solid transparent;
border-radius: 3px;
margin-bottom: $baseline/4;
a.outline-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: ($baseline / 2);
.subsection-title {
margin: 0;
}
&:hover,
&:focus {
background-color: palette(primary, x-back);
border-radius: $btn-border-radius;
text-decoration: none;
}
.subsection-text {
.details {
font-size: $body-font-size;
color: theme-color("secondary");
}
}
.subsection-actions {
.resume-right {
position: relative;
top: calc(50% - (#{$baseline} / 2));
}
}
}
&.current {
border: 1px solid theme-color("primary");
border-radius: $btn-border-radius;
.resume-right {
@include float(right);
}
}
&:hover {
border: 1px solid theme-color("primary");
}
&:last-child {
margin-bottom: 0;
}
}
&:last-child {
border-bottom: none;
}
}
}
}
}
// date summary
.date-summary-container {
.date-summary {

View File

@@ -1,4 +1,11 @@
<section class="course-outline" id="main">
<button class="btn btn-primary"
id="expand-collapse-outline-all-button"
aria-expanded="false"
aria-controls="course-outline-block-tree"
>
<span class="expand-collapse-outline-all-extra-padding" id="expand-collapse-outline-all-span">${_("Expand All")}</span>
</button>
<ol class="block-tree" role="tree">
<li aria-expanded="true" class="outline-item focusable" id="block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b"
role="treeitem" tabindex="0">

View File

@@ -4,7 +4,7 @@ import { keys } from 'edx-ui-toolkit/js/utils/constants';
// @TODO: Figure out how to make webpack handle default exports when libraryTarget: 'window'
export class CourseOutline { // eslint-disable-line import/prefer-default-export
constructor(newCourseOutlineEnabled) {
constructor() {
const focusable = [...document.querySelectorAll('.outline-item.focusable')];
focusable.forEach(el => el.addEventListener('keydown', (event) => {
@@ -54,49 +54,46 @@ export class CourseOutline { // eslint-disable-line import/prefer-default-expor
sectionToggleButton.setAttribute('aria-expanded', 'false');
}
// TODO: EDUCATOR-2283 Remove check for waffle flag after it is turned on.
if (newCourseOutlineEnabled) {
[...document.querySelectorAll(('.accordion'))]
.forEach((accordion) => {
const sections = Array.prototype.slice.call(accordion.querySelectorAll('.accordion-trigger'));
[...document.querySelectorAll(('.accordion'))]
.forEach((accordion) => {
const sections = Array.prototype.slice.call(accordion.querySelectorAll('.accordion-trigger'));
sections.forEach(section => section.addEventListener('click', (event) => {
const sectionToggleButton = event.currentTarget;
if (sectionToggleButton.classList.contains('accordion-trigger')) {
const isExpanded = sectionToggleButton.getAttribute('aria-expanded') === 'true';
if (!isExpanded) {
expandSection(sectionToggleButton);
} else if (isExpanded) {
collapseSection(sectionToggleButton);
}
event.stopImmediatePropagation();
sections.forEach(section => section.addEventListener('click', (event) => {
const sectionToggleButton = event.currentTarget;
if (sectionToggleButton.classList.contains('accordion-trigger')) {
const isExpanded = sectionToggleButton.getAttribute('aria-expanded') === 'true';
if (!isExpanded) {
expandSection(sectionToggleButton);
} else if (isExpanded) {
collapseSection(sectionToggleButton);
}
}));
});
const toggleAllButton = document.querySelector('#expand-collapse-outline-all-button');
const toggleAllSpan = document.querySelector('#expand-collapse-outline-all-span');
const extraPaddingClass = 'expand-collapse-outline-all-extra-padding';
toggleAllButton.addEventListener('click', (event) => {
const toggleAllExpanded = toggleAllButton.getAttribute('aria-expanded') === 'true';
let sectionAction;
if (toggleAllExpanded) {
toggleAllButton.setAttribute('aria-expanded', 'false');
sectionAction = collapseSection;
toggleAllSpan.classList.add(extraPaddingClass);
toggleAllSpan.innerText = 'Expand All';
} else {
toggleAllButton.setAttribute('aria-expanded', 'true');
sectionAction = expandSection;
toggleAllSpan.classList.remove(extraPaddingClass);
toggleAllSpan.innerText = 'Collapse All';
}
const sections = Array.prototype.slice.call(document.querySelectorAll('.accordion-trigger'));
sections.forEach((sectionToggleButton) => {
sectionAction(sectionToggleButton);
});
event.stopImmediatePropagation();
event.stopImmediatePropagation();
}
}));
});
}
const toggleAllButton = document.querySelector('#expand-collapse-outline-all-button');
const toggleAllSpan = document.querySelector('#expand-collapse-outline-all-span');
const extraPaddingClass = 'expand-collapse-outline-all-extra-padding';
toggleAllButton.addEventListener('click', (event) => {
const toggleAllExpanded = toggleAllButton.getAttribute('aria-expanded') === 'true';
let sectionAction;
if (toggleAllExpanded) {
toggleAllButton.setAttribute('aria-expanded', 'false');
sectionAction = collapseSection;
toggleAllSpan.classList.add(extraPaddingClass);
toggleAllSpan.innerText = 'Expand All';
} else {
toggleAllButton.setAttribute('aria-expanded', 'true');
sectionAction = expandSection;
toggleAllSpan.classList.remove(extraPaddingClass);
toggleAllSpan.innerText = 'Collapse All';
}
const sections = Array.prototype.slice.call(document.querySelectorAll('.accordion-trigger'));
sections.forEach((sectionToggleButton) => {
sectionAction(sectionToggleButton);
});
event.stopImmediatePropagation();
});
}
}

View File

@@ -1,194 +0,0 @@
## mako
<%page expression_filter="h"/>
<%namespace name='static' file='../static_content.html'/>
<%!
from datetime import date
from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<main role="main" class="course-outline-visualprogress" id="main" tabindex="-1">
% if blocks.get('children'):
<button class="btn btn-primary"
id="expand-collapse-outline-all-button"
aria-expanded="false"
aria-controls="course-outline-block-tree"
>
<span class="expand-collapse-outline-all-extra-padding" id="expand-collapse-outline-all-span">${_("Expand All")}</span>
</button>
<ol class="block-tree accordion"
id="course-outline-block-tree"
role="presentation"
aria-labeledby="expand-collapse-outline-all-button">
% for section in blocks.get('children'):
<% section_is_auto_opened = section.get('resume_block') is True %>
<li
class="outline-item section"
role="heading"
>
<button class="section-name accordion-trigger"
aria-expanded="${ 'true' if section_is_auto_opened else 'false' }"
aria-controls="${ section['id'] }_contents"
id="${ section['id'] }">
<span class="fa fa-chevron-right ${ 'fa-rotate-90' if section_is_auto_opened else '' }" aria-hidden="true"></span>
<h3 class="section-title">${ section['display_name'] }</h3>
% if section.get('complete'):
<span class="complete-checkmark fa fa-check"></span>
% endif
</button>
<ol class="outline-item accordion-panel ${ '' if section_is_auto_opened else 'is-hidden' }"
id="${ section['id'] }_contents"
role="region"
aria-labelledby="${ section['id'] }"
>
% for subsection in section.get('children', []):
<%
gated_subsection = subsection['id'] in gated_content
completed_prereqs = gated_content[subsection['id']]['completed_prereqs'] if gated_subsection else False
subsection_is_auto_opened = subsection.get('resume_block') is True
%>
<li class="subsection accordion ${ 'current' if subsection['resume_block'] else '' }" role="heading">
% if gated_subsection and not completed_prereqs:
<a href="${ subsection['lms_web_url'] }">
<button class="subsection-text prerequisite-button"
id="${ subsection['id'] }"
>
<span class="menu-icon icon fa fa-lock"
aria-hidden="true">
</span>
<span class="subsection-title">
${ subsection['display_name'] }
</span>
<div class="details prerequisite">
${ _("Prerequisite: ") }
<%
prerequisite_id = gated_content[subsection['id']]['prerequisite']
prerequisite_name = xblock_display_names.get(prerequisite_id)
%>
${ prerequisite_name }
</div>
% else:
<button class="subsection-text accordion-trigger"
id="${ subsection['id'] }"
aria-expanded="${ 'true' if subsection_is_auto_opened else 'false' }"
aria-controls="${ subsection['id'] }_contents"
>
<span class="fa fa-chevron-right ${ 'fa-rotate-90' if subsection_is_auto_opened else '' }"
aria-hidden="true"></span>
<span class="subsection-title">
${ subsection['display_name'] }
</span>
% if subsection.get('complete'):
<span class="complete-checkmark fa fa-check"></span>
% endif
% endif
<div class="details">
## There are behavior differences between rendering of subsections which have
## exams (timed, graded, etc) and those that do not.
##
## Exam subsections expose exam status message field as well as a status icon
<%
if subsection.get('due') is None:
# examples: Homework, Lab, etc.
data_string = subsection.get('format')
else:
if 'special_exam_info' in subsection:
data_string = _('due {date}')
else:
data_string = _("{subsection_format} due {{date}}").format(subsection_format=subsection.get('format'))
%>
% if subsection.get('format') or 'special_exam_info' in subsection:
<span class="subtitle">
% if 'special_exam' in subsection:
## Display the exam status icon and status message
<span
class="menu-icon icon fa ${subsection['special_exam_info'].get('suggested_icon', 'fa-pencil-square-o')} ${subsection['special_exam_info'].get('status', 'eligible')}"
aria-hidden="true"
></span>
<span class="subtitle-name">
${subsection['special_exam_info'].get('short_description', '')}
</span>
## completed exam statuses should not show the due date
## since the exam has already been submitted by the user
% if not subsection['special_exam_info'].get('in_completed_state', False):
<span
class="localized-datetime subtitle-name"
data-datetime="${subsection.get('due')}"
data-string="${data_string}"
data-format= "${due_date_display_format}"
data-timezone="${user_timezone}"
data-language="${user_language}"
></span>
% endif
% else:
## non-graded section, we just show the exam format and the due date
## this is the standard case in edx-platform
<span
class="localized-datetime subtitle-name"
data-datetime="${subsection.get('due')}"
data-format= "${due_date_display_format}"
data-string="${data_string}"
data-timezone="${user_timezone}"
data-language="${user_language}"
></span>
% if subsection.get('graded'):
<span class="sr">&nbsp;${_("This content is graded")}</span>
% endif
% endif
</span>
% endif
</div> <!-- /details -->
</button> <!-- /subsection-text -->
% if gated_subsection and not completed_prereqs:
</a>
% endif
% if not gated_subsection or (gated_subsection and completed_prereqs):
<ol class="outline-item accordion-panel ${ '' if subsection_is_auto_opened else 'is-hidden' }"
id="${ subsection['id'] }_contents"
role="region"
aria-labelledby="${ subsection['id'] }"
>
% for vertical in subsection.get('children', []):
<li class="vertical outline-item focusable">
<a
class="outline-item focusable"
href="${ vertical['lms_web_url'] }"
id="${ vertical['id'] }"
>
<div class="vertical-details">
<span class="vertical-title">
${ vertical['display_name'] }
</span>
</div>
% if vertical.get('complete'):
<span class="complete-checkmark fa fa-check"></span>
% endif
</a>
</li>
% endfor
</ol>
% endif
</li>
% endfor
</ol>
</li>
% endfor
</ol>
% endif
</main>
<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory">
DateUtilFactory.transform('.localized-datetime');
</%static:require_module_async>
<%static:webpack entry="CourseOutline">
new CourseOutline('.block-tree', true);
</%static:webpack>

View File

@@ -1,153 +0,0 @@
## mako
<%page expression_filter="h"/>
<%namespace name='static' file='../static_content.html'/>
<%!
from datetime import date
from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<main class="course-outline" id="main" aria-label="Content" tabindex="-1">
% if blocks.get('children'):
<ol class="block-tree">
% for section in blocks.get('children'):
<li
class="outline-item focusable section"
id="${ section['id'] }"
>
<div class="section-name">
<h3 class="section-title">${ section['display_name'] }</h3>
</div>
<ol class="outline-item focusable">
% for subsection in section.get('children', []):
<li
class="subsection ${ 'current' if subsection['resume_block'] else '' }"
>
<a
class="outline-item focusable"
href="${ subsection['lms_web_url'] }"
id="${ subsection['id'] }"
>
<div class="subsection-text">
## Subsection title
<span class="subsection-title">
% if subsection['id'] in gated_content:
% if gated_content[subsection['id']]['completed_prereqs']:
<span class="menu-icon icon fa fa-unlock"
aria-hidden="true">
</span>
<span class="subsection-title-name">
${ subsection['display_name'] }
</span>
<span class="sr">&nbsp;${_("Unlocked")}</span>
% else:
<span class="menu-icon icon fa fa-lock"
aria-hidden="true">
</span>
<span class="subsection-title-name">
${ subsection['display_name'] }
</span>
<span class="details">
${ _("(Prerequisite required)") }
</span>
% endif
% else:
<span class="subsection-title-name">
${ subsection['display_name'] }
</span>
% endif
<div class="details">
## There are behavior differences between rendering of subsections which have
## exams (timed, graded, etc) and those that do not.
##
## Exam subsections expose exam status message field as well as a status icon
<%
if subsection.get('due') is None:
# examples: Homework, Lab, etc.
data_string = subsection.get('format')
else:
if 'special_exam_info' in subsection:
data_string = _('due {date}')
else:
data_string = _("{subsection_format} due {{date}}").format(subsection_format=subsection.get('format'))
%>
% if subsection.get('format') or 'special_exam_info' in subsection:
<span class="subtitle">
% if 'special_exam' in subsection:
## Display the exam status icon and status message
<span
class="menu-icon icon fa ${subsection['special_exam_info'].get('suggested_icon', 'fa-pencil-square-o')} ${subsection['special_exam_info'].get('status', 'eligible')}"
aria-hidden="true"
></span>
<span class="subtitle-name">
${subsection['special_exam_info'].get('short_description', '')}
</span>
## completed exam statuses should not show the due date
## since the exam has already been submitted by the user
% if not subsection['special_exam_info'].get('in_completed_state', False):
<span
class="localized-datetime subtitle-name"
data-datetime="${subsection.get('due')}"
data-format= "${due_date_display_format}"
data-string="${data_string}"
data-timezone="${user_timezone}"
data-language="${user_language}"
></span>
% endif
% else:
## non-graded section, we just show the exam format and the due date
## this is the standard case in edx-platform
<span
class="localized-datetime subtitle-name"
data-datetime="${subsection.get('due')}"
data-format= "${due_date_display_format}"
data-string="${data_string}"
data-timezone="${user_timezone}"
data-language="${user_language}"
></span>
% if 'graded' in subsection and subsection['graded']:
<span
class="menu-icon icon fa fa-pencil-square-o"
aria-hidden="true"
></span>
<span class="sr">&nbsp;${_("This content is graded")}</span>
% endif
% endif
</span>
% endif
</div> <!-- /details -->
</div> <!-- /subsection-text -->
<div class="subsection-actions">
## Resume button (if last visited section)
% if subsection['resume_block']:
<span class="resume-right">
<b>${ _("Resume Course") }</b>
<span class="icon fa fa-arrow-circle-right" aria-hidden="true"></span>
</span>
%endif
</div>
</a>
</li>
% endfor
</ol>
</li>
% endfor
</ol>
% endif
</main>
<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory">
DateUtilFactory.transform('.localized-datetime');
</%static:require_module_async>
<%static:webpack entry="CourseOutline">
new CourseOutline('.block-tree', false);
</%static:webpack>

View File

@@ -0,0 +1,189 @@
## mako
<%page expression_filter="h"/>
<%namespace name='static' file='../static_content.html'/>
<%!
from datetime import date
from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<%
course_sections = blocks.get('children')
%>
<main role="main" class="course-outline" id="main" tabindex="-1">
% if course_sections is not None:
<button class="btn btn-primary"
id="expand-collapse-outline-all-button"
aria-expanded="false"
aria-controls="course-outline-block-tree"
>
<span class="expand-collapse-outline-all-extra-padding" id="expand-collapse-outline-all-span">${_("Expand All")}</span>
</button>
<ol class="block-tree accordion"
id="course-outline-block-tree"
role="presentation"
aria-labelledby="expand-collapse-outline-all-button">
% for section in course_sections:
<%
section_is_auto_opened = section.get('resume_block') is True
%>
<li class="outline-item section" role="heading">
<button class="section-name accordion-trigger"
aria-expanded="${ 'true' if section_is_auto_opened else 'false' }"
aria-controls="${ section['id'] }_contents"
id="${ section['id'] }">
<span class="fa fa-chevron-right ${ 'fa-rotate-90' if section_is_auto_opened else '' }" aria-hidden="true"></span>
<h3 class="section-title">${ section['display_name'] }</h3>
% if show_visual_progress and section.get('complete'):
<span class="complete-checkmark fa fa-check"></span>
% endif
</button>
<ol class="outline-item accordion-panel ${ '' if section_is_auto_opened else 'is-hidden' }"
id="${ section['id'] }_contents"
role="region"
aria-labelledby="${ section['id'] }">
% for subsection in section.get('children', []):
<%
gated_subsection = subsection['id'] in gated_content
completed_prereqs = gated_content[subsection['id']]['completed_prereqs'] if gated_subsection else False
subsection_is_auto_opened = subsection.get('resume_block') is True
%>
<li class="subsection accordion ${ 'current' if subsection['resume_block'] else '' }" role="heading">
% if gated_subsection and not completed_prereqs:
<a href="${ subsection['lms_web_url'] }">
<button class="subsection-text prerequisite-button"
id="${ subsection['id'] }">
<span class="menu-icon icon fa fa-lock"
aria-hidden="true">
</span>
<span class="subsection-title">
${ subsection['display_name'] }
</span>
<div class="details prerequisite">
${ _("Prerequisite: ") }
<%
prerequisite_id = gated_content[subsection['id']]['prerequisite']
prerequisite_name = xblock_display_names.get(prerequisite_id)
%>
${ prerequisite_name }
</div>
% else:
<button class="subsection-text accordion-trigger"
id="${ subsection['id'] }"
aria-expanded="${ 'true' if subsection_is_auto_opened else 'false' }"
aria-controls="${ subsection['id'] }_contents">
<span class="fa fa-chevron-right ${ 'fa-rotate-90' if subsection_is_auto_opened else '' }"
aria-hidden="true"></span>
<span class="subsection-title">
${ subsection['display_name'] }
</span>
% if show_visual_progress and subsection.get('complete'):
<span class="complete-checkmark fa fa-check"></span>
% endif
% endif
<div class="details">
## There are behavior differences between rendering of subsections which have
## exams (timed, graded, etc) and those that do not.
##
## Exam subsections expose exam status message field as well as a status icon
<%
if subsection.get('due') is None:
# examples: Homework, Lab, etc.
data_string = subsection.get('format')
else:
if 'special_exam_info' in subsection:
data_string = _('due {date}')
else:
data_string = _("{subsection_format} due {{date}}").format(subsection_format=subsection.get('format'))
%>
% if subsection.get('format') or 'special_exam_info' in subsection:
<span class="subtitle">
% if 'special_exam' in subsection:
## Display the exam status icon and status message
<span
class="menu-icon icon fa ${subsection['special_exam_info'].get('suggested_icon', 'fa-pencil-square-o')} ${subsection['special_exam_info'].get('status', 'eligible')}"
aria-hidden="true"
></span>
<span class="subtitle-name">
${subsection['special_exam_info'].get('short_description', '')}
</span>
## completed exam statuses should not show the due date
## since the exam has already been submitted by the user
% if not subsection['special_exam_info'].get('in_completed_state', False):
<span
class="localized-datetime subtitle-name"
data-datetime="${subsection.get('due')}"
data-string="${data_string}"
data-timezone="${user_timezone}"
data-language="${user_language}"
></span>
% endif
% else:
## non-graded section, we just show the exam format and the due date
## this is the standard case in edx-platform
<span
class="localized-datetime subtitle-name"
data-datetime="${subsection.get('due')}"
data-string="${data_string}"
data-timezone="${user_timezone}"
data-language="${user_language}"
></span>
% if subsection.get('graded'):
<span class="sr">&nbsp;${_("This content is graded")}</span>
% endif
% endif
</span>
% endif
</div> <!-- /details -->
</button> <!-- /subsection-text -->
% if gated_subsection and not completed_prereqs:
</a>
% endif
% if not gated_subsection or (gated_subsection and completed_prereqs):
<ol class="outline-item accordion-panel ${ '' if subsection_is_auto_opened else 'is-hidden' }"
id="${ subsection['id'] }_contents"
role="region"
aria-labelledby="${ subsection['id'] }"
>
% for vertical in subsection.get('children', []):
<li class="vertical outline-item focusable">
<a class="outline-item focusable"
href="${ vertical['lms_web_url'] }"
id="${ vertical['id'] }">
<div class="vertical-details">
<span class="vertical-title">
${ vertical['display_name'] }
</span>
</div>
% if show_visual_progress and vertical.get('complete'):
<span class="complete-checkmark fa fa-check"></span>
% endif
</a>
</li>
% endfor
</ol>
% endif
</li>
% endfor
</ol>
</li>
% endfor
</ol>
% endif
</main>
<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory">
DateUtilFactory.transform('.localized-datetime');
</%static:require_module_async>
<%static:webpack entry="CourseOutline">
new CourseOutline();
</%static:webpack>

View File

@@ -131,7 +131,7 @@ class TestCourseOutlinePage(SharedModuleStoreTestCase):
self.assertIn(sequential.format, response_content)
self.assertTrue(sequential.children)
for vertical in sequential.children:
self.assertNotIn(vertical.display_name, response_content)
self.assertIn(vertical.display_name, response_content)
class TestCourseOutlinePageWithPrerequisites(SharedModuleStoreTestCase, MilestonesTestCaseMixin):
@@ -230,21 +230,21 @@ class TestCourseOutlinePageWithPrerequisites(SharedModuleStoreTestCase, Mileston
lock_icon = response_content('.fa-lock')
self.assertTrue(lock_icon, "lock icon is not present, but should be")
subsection = lock_icon.parents('.subsection-title')
subsection = lock_icon.parents('.subsection-text')
# check that subsection-title-name is the display name
gated_subsection_title = self.course_blocks['gated_content'].display_name
self.assertIn(gated_subsection_title, subsection.children('.subsection-title-name').html())
self.assertIn(gated_subsection_title, subsection.children('.subsection-title').html())
# check that it says prerequisite required
self.assertIn(self.PREREQ_REQUIRED, subsection.children('.details').html())
self.assertIn("Prerequisite:", subsection.children('.details').html())
# check that there is not a screen reader message
self.assertFalse(subsection.children('.sr'))
def test_content_unlocked(self):
"""
Test that a sequential/subsection with unmet prereqs correctly indicated that its content is locked
Test that a sequential/subsection with met prereqs correctly indicated that its content is unlocked
"""
course = self.course
self.setup_gated_section(self.course_blocks['gated_content'], self.course_blocks['prerequisite'])
@@ -263,24 +263,23 @@ class TestCourseOutlinePageWithPrerequisites(SharedModuleStoreTestCase, Mileston
response_content = pq(response.content)
# check unlock icon is present
# check unlock icon is not present
unlock_icon = response_content('.fa-unlock')
self.assertTrue(unlock_icon, "unlock icon is not present, but should be")
self.assertFalse(unlock_icon, "unlock icon is present, yet shouldn't be.")
subsection = unlock_icon.parents('.subsection-title')
gated_subsection_title = self.course_blocks['gated_content'].display_name
every_subsection_on_outline = response_content('.subsection-title')
subsection_has_gated_text = False
says_prerequisite_required = False
for subsection_contents in every_subsection_on_outline.contents():
subsection_has_gated_text = gated_subsection_title in subsection_contents
says_prerequisite_required = "Prerequisite:" in subsection_contents
# check that subsection-title-name is the display name of gated content section
gated_subsection_title = self.course_blocks['gated_content'].display_name
self.assertIn(gated_subsection_title, subsection.children('.subsection-title-name').html())
# check that it doesn't say prerequisite required
self.assertNotIn(self.PREREQ_REQUIRED, subsection.children('.subsection-title-name').html())
# check that there is a screen reader message
self.assertTrue(subsection.children('.sr'))
# check that the screen reader message is correct
self.assertIn(self.UNLOCKED, subsection.children('.sr').html())
self.assertTrue(subsection_has_gated_text)
self.assertFalse(says_prerequisite_required)
class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase, CompletionWaffleTestMixin):
@@ -415,7 +414,7 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase, CompletionWaffleT
self.complete_sequential(self.course, vertical1)
# Test for 'resume' link
response = self.visit_course_home(course, resume_count=2)
response = self.visit_course_home(course, resume_count=1)
# Test for 'resume' link URL - should be vertical 1
content = pq(response.content)
@@ -423,7 +422,7 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase, CompletionWaffleT
self.complete_sequential(self.course, vertical2)
# Test for 'resume' link
response = self.visit_course_home(course, resume_count=2)
response = self.visit_course_home(course, resume_count=1)
# Test for 'resume' link URL - should be vertical 2
content = pq(response.content)
@@ -434,7 +433,7 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase, CompletionWaffleT
self.visit_sequential(course, course.children[0], course.children[0].children[0])
# Test for 'resume' link URL - should be vertical 2 (last completed block, NOT last visited)
response = self.visit_course_home(course, resume_count=2)
response = self.visit_course_home(course, resume_count=1)
content = pq(response.content)
self.assertTrue(content('.action-resume-course').attr('href').endswith('/vertical/' + vertical2.url_name))
@@ -459,7 +458,7 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase, CompletionWaffleT
self.store.delete_item(sequential.location, self.user.id)
# check resume course buttons
response = self.visit_course_home(course, resume_count=2)
response = self.visit_course_home(course, resume_count=1)
content = pq(response.content)
self.assertTrue(content('.action-resume-course').attr('href').endswith('/sequential/' + sequential2.url_name))
@@ -522,22 +521,21 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase, CompletionWaffleT
"aria-labelledby=\"" + url + "\"" \
">"
with patch('openedx.features.course_experience.waffle.new_course_outline_enabled', Mock(return_value=True)):
# Course tree
course = self.course
chapter = course.children[0]
sequential1 = chapter.children[0]
sequential2 = chapter.children[1]
# Course tree
course = self.course
chapter = course.children[0]
sequential1 = chapter.children[0]
sequential2 = chapter.children[1]
response_content = self.client.get(course_home_url(course)).content
stripped_response = text_type(re.sub("\\s+", "", response_content), "utf-8")
response_content = self.client.get(course_home_url(course)).content
stripped_response = text_type(re.sub("\\s+", "", response_content), "utf-8")
self.assertTrue(get_sequential_button(text_type(sequential1.location), False) in stripped_response)
self.assertTrue(get_sequential_button(text_type(sequential2.location), True) in stripped_response)
self.assertTrue(get_sequential_button(text_type(sequential1.location), False) in stripped_response)
self.assertTrue(get_sequential_button(text_type(sequential2.location), True) in stripped_response)
content = pq(response_content)
button = content('#expand-collapse-outline-all-button')
self.assertEqual('Expand All', button.children()[0].text)
content = pq(response_content)
button = content('#expand-collapse-outline-all-button')
self.assertEqual('Expand All', button.children()[0].text)
def test_user_enrolled_after_completion_collection(self):
"""

View File

@@ -15,7 +15,6 @@ from web_fragments.fragment import Fragment
from courseware.courses import get_course_overview_with_access
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
from openedx.features.course_experience import waffle as course_experience_waffle
from student.models import CourseEnrollment
from util.milestones_helpers import get_course_content_milestones
@@ -50,29 +49,18 @@ class CourseOutlineFragmentView(EdxFragmentView):
'blocks': course_block_tree
}
# TODO: EDUCATOR-2283 Remove this check when the waffle flag is turned on in production
if course_experience_waffle.new_course_outline_enabled(course_key=course_key):
resume_block = get_resume_block(course_block_tree)
if not resume_block:
self.mark_first_unit_to_resume(course_block_tree)
resume_block = get_resume_block(course_block_tree)
if not resume_block:
self.mark_first_unit_to_resume(course_block_tree)
xblock_display_names = self.create_xblock_id_and_name_dict(course_block_tree)
gated_content = self.get_content_milestones(request, course_key)
xblock_display_names = self.create_xblock_id_and_name_dict(course_block_tree)
gated_content = self.get_content_milestones(request, course_key)
context['gated_content'] = gated_content
context['xblock_display_names'] = xblock_display_names
context['gated_content'] = gated_content
context['xblock_display_names'] = xblock_display_names
# TODO: EDUCATOR-2283 Rename this file to course-outline-fragment.html
html = render_to_string('course_experience/course-outline-fragment-new.html', context)
return Fragment(html)
else:
content_milestones = self.get_content_milestones_old(request, course_key)
context['gated_content'] = content_milestones
# TODO: EDUCATOR-2283 Remove this file
html = render_to_string('course_experience/course-outline-fragment-old.html', context)
return Fragment(html)
html = render_to_string('course_experience/course-outline-fragment.html', context)
return Fragment(html)
def create_xblock_id_and_name_dict(self, course_block_tree, xblock_display_names=None):
"""
@@ -114,28 +102,6 @@ class CourseOutlineFragmentView(EdxFragmentView):
return gated_content
# TODO: EDUCATOR-2283 Remove this function when the visual progress waffle flag is turned on in production
def get_content_milestones_old(self, request, course_key):
"""
Returns dict of subsections with prerequisites and whether the prerequisite has been completed or not
"""
all_course_prereqs = get_course_content_milestones(course_key)
content_ids_of_unfulfilled_prereqs = {
milestone['content_id']
for milestone in get_course_content_milestones(course_key, user_id=request.user.id)
}
course_content_milestones = {
milestone['content_id']: {
'completed_prereqs': milestone['content_id'] not in content_ids_of_unfulfilled_prereqs
}
for milestone in all_course_prereqs
}
return course_content_milestones
def user_enrolled_after_completion_collection(self, user, course_key):
"""
Checks that the user has enrolled in the course after 01/24/2018, the date that

View File

@@ -1,63 +0,0 @@
"""
This module contains various configuration settings via
waffle switches for the course experience app.
"""
from __future__ import unicode_literals
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
from openedx.core.djangoapps.theming.helpers import get_current_site
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlagNamespace, WaffleSwitchNamespace
# Namespace
WAFFLE_NAMESPACE = 'course_experience'
# Switches
# Full name course_experience.enable_new_course_outline
# Enables the UI changes to the course outline for all courses
ENABLE_NEW_COURSE_OUTLINE = 'enable_new_course_outline'
# Full name course_experience.enable_new_course_outline_for_course
# Enables the UI changes to the course outline for a course
ENABLE_NEW_COURSE_OUTLINE_FOR_COURSE = 'enable_new_course_outline_for_course'
# Full name course_experience.enable_new_course_outline_for_site
# Enables the UI changes to the course outline for a site configuration
ENABLE_NEW_COURSE_OUTLINE_FOR_SITE = 'enable_new_course_outline_for_site'
def waffle_switch():
"""
Returns the namespaced, cached, audited Waffle class for course experience.
"""
return WaffleSwitchNamespace(name=WAFFLE_NAMESPACE, log_prefix='course_experience: ')
def waffle_flag():
"""
Returns the namespaced, cached, audited Waffle flags dictionary for course experience.
"""
namespace = WaffleFlagNamespace(name=WAFFLE_NAMESPACE, log_prefix=u'course_experience: ')
# By default, disable the new course outline. Can be enabled on a course-by-course basis.
# And overridden site-globally by ENABLE_SITE_NEW_COURSE_OUTLINE
return CourseWaffleFlag(
namespace,
ENABLE_NEW_COURSE_OUTLINE_FOR_COURSE,
flag_undefined_default=False
)
def new_course_outline_enabled(course_key):
"""
Returns whether the new course outline is enabled.
"""
try:
current_site = get_current_site()
if not current_site.configuration.get_value(ENABLE_NEW_COURSE_OUTLINE_FOR_SITE, False):
return
except SiteConfiguration.DoesNotExist:
return
if not waffle_switch().is_enabled(ENABLE_NEW_COURSE_OUTLINE):
return waffle_flag().is_enabled(course_key)
return True