Surface vertical units in the course outline
This commit is contained in:
committed by
Sofiya Semenova
parent
5cca46d161
commit
db81dfa424
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"> ${_("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"> ${_("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>
|
||||
@@ -46,7 +46,7 @@ from openedx.core.djangolib.markup import HTML, Text
|
||||
</span>
|
||||
<span class="sr"> ${_("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>
|
||||
@@ -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']: {
|
||||
|
||||
63
openedx/features/course_experience/waffle.py
Normal file
63
openedx/features/course_experience/waffle.py
Normal 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
|
||||
Reference in New Issue
Block a user