Surface vertical units in the course outline

This commit is contained in:
Simon Chen
2018-01-12 15:57:23 -05:00
committed by Sofiya Semenova
parent 5cca46d161
commit db81dfa424
6 changed files with 459 additions and 12 deletions

View File

@@ -301,6 +301,119 @@
}
}
// Course outline for visual progress waffle switch
.course-outline-visualprogress {
.block-tree {
margin: 0;
padding: 0;
list-style-type: none;
.section {
@include media-breakpoint-up(md) {
margin: 0;
}
margin: 0 (-1 * $baseline) 0 ($baseline);
width: calc(100% + (2));
padding: 0;
border-bottom: 1px solid $border-color;
.section-name {
padding: ($baseline / 2) 0 ($baseline / 2) 0;
.section-title {
font-weight: $font-bold;
font-size: 1.1rem;
margin: 0;
display: inline;
padding-left: $baseline;
}
}
.outline-item {
@include padding-left(0);
}
ol.outline-item {
margin: 0;
.subsection {
list-style-type: none;
border-top: 1px solid $border-color;
margin: 0 0 ($baseline / 4) 35px;
.subsection-title {
margin: 0;
font-weight: $font-bold;
margin-left: $baseline;
}
.subsection-text {
.details {
font-size: $body-font-size;
color: theme-color("secondary");
margin-left: 35px;
}
.prerequisite {
color: theme-color("secondary");
font-weight: $font-bold;
}
}
.vertical {
@include margin-left(10px);
list-style-type: none;
border: 1px solid transparent;
border-radius: 2px;
a.outline-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: ($baseline / 2) 0 ($baseline / 2) 0;
margin: 0 0 0 ($baseline);
border-top: 1px solid $border-color;
}
&:hover,
&:focus {
background-color: palette(primary, x-back);
border-radius: $btn-border-radius;
text-decoration: none;
}
.vertical-actions {
.resume-right {
position: relative;
top: calc(50% - (#{$baseline} / 2));
}
}
}
}
}
}
}
}
// Course outline accordion
button.accordion-trigger {
margin: 2px;
padding: 10px 0 10px 0;
border: none;
width: 100%;
text-align: left;
.fa {
color: $blue;
}
}
.accordion-panel.is-hidden {
display: none;
}
// Course outline
.course-outline {
.block-tree {

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() {
constructor(newCourseOutlineEnabled) {
const focusable = [...document.querySelectorAll('.outline-item.focusable')];
focusable.forEach(el => el.addEventListener('keydown', (event) => {
@@ -33,5 +33,37 @@ export class CourseOutline { // eslint-disable-line import/prefer-default-expor
);
}),
);
// 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'));
sections.forEach(section => section.addEventListener('click', (event) => {
const sectionToggleButton = event.currentTarget;
const $toggleButtonChevron = $(sectionToggleButton).children('.fa-chevron-right');
if (sectionToggleButton.classList.contains('accordion-trigger')) {
const isExpanded = sectionToggleButton.getAttribute('aria-expanded') === 'true';
const $contentPanel = $(document.getElementById(sectionToggleButton.getAttribute('aria-controls')));
if (!isExpanded) {
$contentPanel.slideDown();
$contentPanel.removeClass('is-hidden');
$toggleButtonChevron.addClass('fa-rotate-90');
sectionToggleButton.setAttribute('aria-expanded', 'true');
} else if (isExpanded) {
$contentPanel.slideUp();
$contentPanel.addClass('is-hidden');
$toggleButtonChevron.removeClass('fa-rotate-90');
sectionToggleButton.setAttribute('aria-expanded', 'false');
}
event.stopImmediatePropagation();
}
}));
});
}
}
}

View File

@@ -0,0 +1,180 @@
## 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'):
<ol class="block-tree accordion" role="presentation">
% for section in blocks.get('children'):
<li
class="outline-item section"
role="heading"
>
<button class="section-name accordion-trigger"
aria-expanded="false"
aria-controls="${ section['id'] }_contents"
id="${ section['id'] }">
<span class="fa fa-chevron-right" aria-hidden="true"></span>
<h3 class="section-title">${ section['display_name'] }</h3>
</button>
<ol class="outline-item accordion-panel 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
%>
<li class="subsection accordion ${ 'current' if subsection['resume_block'] else '' }" role="heading">
% if gated_subsection and not completed_prereqs:
<button class="subsection-text accordion-trigger"
id="${ subsection['id'] }"
>
% else:
<button class="subsection-text accordion-trigger"
id="${ subsection['id'] }"
aria-expanded="false"
aria-controls="${ subsection['id'] }_contents"
>
% endif
## Subsection title
% if gated_subsection:
% if completed_prereqs:
<span class="menu-icon icon fa fa-unlock"
aria-hidden="true">
</span>
<span class="subsection-title">
${ 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">
${ 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>
% endif
% else:
<span class="fa fa-chevron-right" aria-hidden="true"></span>
<span class="subsection-title">
${ 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-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 not gated_subsection or (gated_subsection and completed_prereqs):
<ol class="outline-item accordion-panel 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>
</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

@@ -46,7 +46,7 @@ from openedx.core.djangolib.markup import HTML, Text
</span>
<span class="sr">&nbsp;${_("Unlocked")}</span>
% else:
<span class="menu-icon icon fa fa-lock"
<span class="menu-icon icon fa fa-lock"
aria-hidden="true">
</span>
<span class="subsection-title-name">
@@ -147,5 +147,5 @@ from openedx.core.djangolib.markup import HTML, Text
</%static:require_module_async>
<%static:webpack entry="CourseOutline">
new CourseOutline('.block-tree');
new CourseOutline('.block-tree', false);
</%static:webpack>

View File

@@ -1,6 +1,8 @@
"""
Views to show a course outline.
"""
import re
from django.template.context_processors import csrf
from django.template.loader import render_to_string
from opaque_keys.edx.keys import CourseKey
@@ -8,10 +10,10 @@ 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 waffle
from ..utils import get_course_outline_block_tree
from util.milestones_helpers import get_course_content_milestones
from xmodule.modulestore.django import modulestore
class CourseOutlineFragmentView(EdxFragmentView):
@@ -30,28 +32,85 @@ class CourseOutlineFragmentView(EdxFragmentView):
if not course_block_tree:
return None
content_milestones = self.get_content_milestones(request, course_key)
context = {
'csrf': csrf(request)['csrf_token'],
'course': course_overview,
'blocks': course_block_tree,
'gated_content': content_milestones
'blocks': course_block_tree
}
html = render_to_string('course_experience/course-outline-fragment.html', context)
return Fragment(html)
# TODO: EDUCATOR-2283 Remove this check when the waffle flag is turned on in production
if waffle.new_course_outline_enabled(course_key=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
# 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)
def create_xblock_id_and_name_dict(self, course_block_tree, xblock_display_names=None):
"""
Creates a dictionary mapping xblock IDs to their names, using a course block tree.
"""
if xblock_display_names is None:
xblock_display_names = {}
if course_block_tree.get('id'):
xblock_display_names[course_block_tree['id']] = course_block_tree['display_name']
if course_block_tree.get('children'):
for child in course_block_tree['children']:
self.create_xblock_id_and_name_dict(child, xblock_display_names)
return xblock_display_names
def get_content_milestones(self, request, course_key):
"""
Returns dict of subsections with prerequisites and whether the prerequisite has been completed or not
"""
def _get_key_of_prerequisite(namespace):
return re.sub('.gating', '', namespace)
all_course_milestones = get_course_content_milestones(course_key)
uncompleted_prereqs = {
milestone['content_id']
for milestone in get_course_content_milestones(course_key, user_id=request.user.id)
}
gated_content = {
milestone['content_id']: {
'completed_prereqs': milestone['content_id'] not in uncompleted_prereqs,
'prerequisite': _get_key_of_prerequisite(milestone['namespace'])
}
for milestone in all_course_milestones
}
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 = [
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']: {

View File

@@ -0,0 +1,63 @@
"""
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