diff --git a/lms/static/sass/features/_course-experience.scss b/lms/static/sass/features/_course-experience.scss index a7e94835bda63f3bc89f6653a31d72e6cb19b395..1e9c68d583488aa03d42fa725af54ca3c34060d0 100644 --- a/lms/static/sass/features/_course-experience.scss +++ b/lms/static/sass/features/_course-experience.scss @@ -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 { diff --git a/openedx/features/course_experience/static/course_experience/js/CourseOutline.js b/openedx/features/course_experience/static/course_experience/js/CourseOutline.js index 08c34978fbd2db8fdb528fb0585be82f3a85664d..9fa21293e14698378bc0c9710f9ab25f6f7299bf 100644 --- a/openedx/features/course_experience/static/course_experience/js/CourseOutline.js +++ b/openedx/features/course_experience/static/course_experience/js/CourseOutline.js @@ -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(); + } + })); + }); + } } } diff --git a/openedx/features/course_experience/templates/course_experience/course-outline-fragment-new.html b/openedx/features/course_experience/templates/course_experience/course-outline-fragment-new.html new file mode 100644 index 0000000000000000000000000000000000000000..b6cfa9ee922ad708fd20dbb57bb02c9f2083f19a --- /dev/null +++ b/openedx/features/course_experience/templates/course_experience/course-outline-fragment-new.html @@ -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> diff --git a/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html b/openedx/features/course_experience/templates/course_experience/course-outline-fragment-old.html similarity index 99% rename from openedx/features/course_experience/templates/course_experience/course-outline-fragment.html rename to openedx/features/course_experience/templates/course_experience/course-outline-fragment-old.html index 03a69f176952a4434efb17efe2b7b5db7049fbe4..0ec6d43a164b8f0dddb8982affd79db290417cb9 100644 --- a/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html +++ b/openedx/features/course_experience/templates/course_experience/course-outline-fragment-old.html @@ -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> diff --git a/openedx/features/course_experience/views/course_outline.py b/openedx/features/course_experience/views/course_outline.py index 59d1d2a0b1b52aedeb7026c9f55688e362fffc11..7822f6cd11bb6698fed561f414ed81c292ddae23 100644 --- a/openedx/features/course_experience/views/course_outline.py +++ b/openedx/features/course_experience/views/course_outline.py @@ -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']: { diff --git a/openedx/features/course_experience/waffle.py b/openedx/features/course_experience/waffle.py new file mode 100644 index 0000000000000000000000000000000000000000..3de0b966414aaa54914855b031bd6b91882c7612 --- /dev/null +++ b/openedx/features/course_experience/waffle.py @@ -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