Skip to content
Snippets Groups Projects
Commit db81dfa4 authored by Simon Chen's avatar Simon Chen Committed by Sofiya Semenova
Browse files

Surface vertical units in the course outline

parent 5cca46d1
No related branches found
No related tags found
No related merge requests found
......@@ -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();
}
}));
});
}
}
}
## 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>
......@@ -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>
"""
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']: {
......
"""
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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment