diff --git a/common/lib/xmodule/xmodule/assets/vertical/public/js/vertical_student_view.js b/common/lib/xmodule/xmodule/assets/vertical/public/js/vertical_student_view.js index 123b1c024c..065480b8b6 100644 --- a/common/lib/xmodule/xmodule/assets/vertical/public/js/vertical_student_view.js +++ b/common/lib/xmodule/xmodule/assets/vertical/public/js/vertical_student_view.js @@ -1,7 +1,7 @@ /* JavaScript for Vertical Student View. */ window.VerticalStudentView = function(runtime, element) { 'use strict'; - RequireJS.require(['js/bookmarks/views/bookmark_button'], function(BookmarkButton) { + RequireJS.require(['course_bookmarks/js/views/bookmark_button'], function(BookmarkButton) { var $element = $(element); var $bookmarkButtonElement = $element.find('.bookmark-button'); @@ -10,7 +10,7 @@ window.VerticalStudentView = function(runtime, element) { bookmarkId: $bookmarkButtonElement.data('bookmarkId'), usageId: $element.data('usageId'), bookmarked: $element.parent('#seq_content').data('bookmarked'), - apiUrl: $('.courseware-bookmarks-button').data('bookmarksApiUrl') + apiUrl: $bookmarkButtonElement.data('bookmarksApiUrl') }); }); }; diff --git a/common/lib/xmodule/xmodule/js/src/sequence/display.js b/common/lib/xmodule/xmodule/js/src/sequence/display.js index 380eebcd9e..b3fb21600c 100644 --- a/common/lib/xmodule/xmodule/js/src/sequence/display.js +++ b/common/lib/xmodule/xmodule/js/src/sequence/display.js @@ -268,7 +268,17 @@ this.updatePageTitle(); sequenceLinks = this.content_container.find('a.seqnav'); sequenceLinks.click(this.goto); - this.path.text(this.el.find('.nav-item.active').data('path')); + + edx.HtmlUtils.setHtml( + this.path, + edx.HtmlUtils.template($('#sequence-breadcrumbs-tpl').text())({ + courseId: this.el.parent().data('course-id'), + blockId: this.id, + pathText: this.el.find('.nav-item.active').data('path'), + unifiedCourseView: this.path.data('unified-course-view') + }) + ); + this.sr_container.focus(); } }; diff --git a/common/static/common/templates/sequence-breadcrumbs.underscore b/common/static/common/templates/sequence-breadcrumbs.underscore new file mode 100644 index 0000000000..da2bd4ba91 --- /dev/null +++ b/common/static/common/templates/sequence-breadcrumbs.underscore @@ -0,0 +1,9 @@ +<% if (unifiedCourseView) { %> + + + <%- gettext('Return to course outline') %> + <%- gettext('Outline') %> + + > +<% } %> +<%- pathText %> diff --git a/common/test/acceptance/fixtures/course.py b/common/test/acceptance/fixtures/course.py index c5b1a05849..00321fd672 100644 --- a/common/test/acceptance/fixtures/course.py +++ b/common/test/acceptance/fixtures/course.py @@ -227,9 +227,9 @@ class CourseFixture(XBlockContainerFixture): self._configure_course() @property - def course_outline(self): + def studio_course_outline_as_json(self): """ - Retrieves course outline in JSON format. + Retrieves Studio course outline in JSON format. """ url = STUDIO_BASE_URL + '/course/' + self._course_key + "?format=json" response = self.session.get(url, headers=self.headers) diff --git a/common/test/acceptance/pages/lms/bookmarks.py b/common/test/acceptance/pages/lms/bookmarks.py index f76f1d9504..66c0fb66d1 100644 --- a/common/test/acceptance/pages/lms/bookmarks.py +++ b/common/test/acceptance/pages/lms/bookmarks.py @@ -10,29 +10,23 @@ class BookmarksPage(CoursePage, PaginatedUIMixin): """ Courseware Bookmarks Page. """ - url = None - url_path = "courseware/" + url_path = "bookmarks" BOOKMARKS_BUTTON_SELECTOR = '.bookmarks-list-button' + BOOKMARKS_ELEMENT_SELECTOR = '#my-bookmarks' BOOKMARKED_ITEMS_SELECTOR = '.bookmarks-results-list .bookmarks-results-list-item' BOOKMARKED_BREADCRUMBS = BOOKMARKED_ITEMS_SELECTOR + ' .list-item-breadcrumbtrail' def is_browser_on_page(self): """ Verify if we are on correct page """ - return self.q(css=self.BOOKMARKS_BUTTON_SELECTOR).visible + return self.q(css=self.BOOKMARKS_ELEMENT_SELECTOR).present def bookmarks_button_visible(self): """ Check if bookmarks button is visible """ return self.q(css=self.BOOKMARKS_BUTTON_SELECTOR).visible - def click_bookmarks_button(self, wait_for_results=True): - """ Click on Bookmarks button """ - self.q(css=self.BOOKMARKS_BUTTON_SELECTOR).first.click() - if wait_for_results: - EmptyPromise(self.results_present, "Bookmarks results present").fulfill() - def results_present(self): """ Check if bookmarks results are present """ - return self.q(css='#my-bookmarks').present + return self.q(css=self.BOOKMARKS_ELEMENT_SELECTOR).present def results_header_text(self): """ Returns the bookmarks results header text """ diff --git a/common/test/acceptance/pages/lms/course_home.py b/common/test/acceptance/pages/lms/course_home.py new file mode 100644 index 0000000000..0c12fb71f9 --- /dev/null +++ b/common/test/acceptance/pages/lms/course_home.py @@ -0,0 +1,155 @@ +""" +LMS Course Home page object +""" + +from bok_choy.page_object import PageObject + +from common.test.acceptance.pages.lms.bookmarks import BookmarksPage +from common.test.acceptance.pages.lms.course_page import CoursePage +from common.test.acceptance.pages.lms.courseware import CoursewarePage + + +class CourseHomePage(CoursePage): + """ + Course home page, including course outline. + """ + + url_path = "course/" + + def is_browser_on_page(self): + return self.q(css='.course-outline').present + + def __init__(self, browser, course_id): + super(CourseHomePage, self).__init__(browser, course_id) + self.course_id = course_id + self.outline = CourseOutlinePage(browser, self) + # TODO: TNL-6546: Remove the following + self.unified_course_view = False + + def click_bookmarks_button(self): + """ Click on Bookmarks button """ + self.q(css='.bookmarks-list-button').first.click() + bookmarks_page = BookmarksPage(self.browser, self.course_id) + bookmarks_page.visit() + + +class CourseOutlinePage(PageObject): + """ + Course outline fragment of page. + """ + + url = None + + def __init__(self, browser, parent_page): + super(CourseOutlinePage, self).__init__(browser) + self.parent_page = parent_page + self.courseware_page = CoursewarePage(self.browser, self.parent_page.course_id) + + def is_browser_on_page(self): + return self.parent_page.is_browser_on_page + + @property + def sections(self): + """ + Return a dictionary representation of sections and subsections. + + Example: + + { + 'Introduction': ['Course Overview'], + 'Week 1': ['Lesson 1', 'Lesson 2', 'Homework'] + 'Final Exam': ['Final Exam'] + } + + You can use these titles in `go_to_section` to navigate to the section. + """ + # Dict to store the result + outline_dict = dict() + + 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: + self.warning("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 + + def go_to_section(self, section_title, subsection_title): + """ + Go to the section in the courseware. + Every section must have at least one subsection, so specify + both the section and subsection title. + + Example: + go_to_section("Week 1", "Lesson 1") + """ + + # Get the section by index + try: + section_index = self._section_titles().index(section_title) + except ValueError: + self.warning("Could not find section '{0}'".format(section_title)) + return + + # Get the subsection by index + try: + subsection_index = self._subsection_titles(section_index + 1).index(subsection_title) + except ValueError: + msg = "Could not find subsection '{0}' in section '{1}'".format(subsection_title, section_title) + self.warning(msg) + return + + # Convert list indices (start at zero) to CSS indices (start at 1) + subsection_css = ( + ".outline-item.section:nth-of-type({0}) .subsection:nth-of-type({1}) .outline-item" + ).format(section_index + 1, subsection_index + 1) + + # Click the subsection and ensure that the page finishes reloading + self.q(css=subsection_css).first.click() + self.courseware_page.wait_for_page() + + # TODO: TNL-6546: Remove this if/visit_unified_course_view + if self.parent_page.unified_course_view: + self.courseware_page.nav.visit_unified_course_view() + + self._wait_for_course_section(section_title, subsection_title) + + def _section_titles(self): + """ + Return a list of all section titles on the page. + """ + section_css = '.section-name span' + return self.q(css=section_css).map(lambda el: el.text.strip()).results + + 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). + """ + # Retrieve the subsection title for the section + # Add one to the list index to get the CSS index, which starts at one + subsection_css = ( + # TODO: TNL-6387: Will need to switch to this selector for subsections + # ".outline-item.section:nth-of-type({0}) .subsection span:nth-of-type(1)" + ".outline-item.section:nth-of-type({0}) .subsection a" + ).format(section_index) + + return self.q( + css=subsection_css + ).map( + lambda el: el.get_attribute('innerHTML').strip() + ).results + + 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. + """ + self.wait_for( + promise_check_func=lambda: self.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) + ) diff --git a/common/test/acceptance/pages/lms/course_nav.py b/common/test/acceptance/pages/lms/course_nav.py deleted file mode 100644 index 154e1fb2f0..0000000000 --- a/common/test/acceptance/pages/lms/course_nav.py +++ /dev/null @@ -1,212 +0,0 @@ -""" -Course navigation page object -""" - -import re -from bok_choy.page_object import PageObject, unguarded -from bok_choy.promise import EmptyPromise - - -class CourseNavPage(PageObject): - """ - Navigate sections and sequences in the courseware. - """ - - url = None - - def is_browser_on_page(self): - return self.q(css='div.course-index').present - - @property - def sections(self): - """ - Return a dictionary representation of sections and subsections. - - Example: - - { - 'Introduction': ['Course Overview'], - 'Week 1': ['Lesson 1', 'Lesson 2', 'Homework'] - 'Final Exam': ['Final Exam'] - } - - You can use these titles in `go_to_section` to navigate to the section. - """ - # Dict to store the result - nav_dict = dict() - - 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: - self.warning("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) - nav_dict[sec_title] = self._subsection_titles(sec_index + 1) - - return nav_dict - - @property - def sequence_items(self): - """ - Return a list of sequence items on the page. - Sequence items are one level below subsections in the course nav. - - Example return value: - ['Chemical Bonds Video', 'Practice Problems', 'Homework'] - """ - seq_css = 'ol#sequence-list>li>.nav-item>.sequence-tooltip' - return self.q(css=seq_css).map(self._clean_seq_titles).results - - def go_to_section(self, section_title, subsection_title): - """ - Go to the section in the courseware. - Every section must have at least one subsection, so specify - both the section and subsection title. - - Example: - go_to_section("Week 1", "Lesson 1") - """ - - # For test stability, disable JQuery animations (opening / closing menus) - self.browser.execute_script("jQuery.fx.off = true;") - - # Get the section by index - try: - sec_index = self._section_titles().index(section_title) - except ValueError: - self.warning("Could not find section '{0}'".format(section_title)) - return - - # Click the section to ensure it's open (no harm in clicking twice if it's already open) - # Add one to convert from list index to CSS index - section_css = '.course-navigation .chapter:nth-of-type({0})'.format(sec_index + 1) - self.q(css=section_css).first.click() - - # Get the subsection by index - try: - subsec_index = self._subsection_titles(sec_index + 1).index(subsection_title) - except ValueError: - msg = "Could not find subsection '{0}' in section '{1}'".format(subsection_title, section_title) - self.warning(msg) - return - - # Convert list indices (start at zero) to CSS indices (start at 1) - subsection_css = ( - ".course-navigation .chapter-content-container:nth-of-type({0}) " - ".menu-item:nth-of-type({1})" - ).format(sec_index + 1, subsec_index + 1) - - # Click the subsection and ensure that the page finishes reloading - self.q(css=subsection_css).first.click() - self._on_section_promise(section_title, subsection_title).fulfill() - - def go_to_vertical(self, vertical_title): - """ - Within a section/subsection, navigate to the vertical with `vertical_title`. - """ - - # Get the index of the item in the sequence - all_items = self.sequence_items - - try: - seq_index = all_items.index(vertical_title) - - except ValueError: - msg = "Could not find sequential '{0}'. Available sequentials: [{1}]".format( - vertical_title, ", ".join(all_items) - ) - self.warning(msg) - - else: - - # Click on the sequence item at the correct index - # Convert the list index (starts at 0) to a CSS index (starts at 1) - seq_css = "ol#sequence-list>li:nth-of-type({0})>.nav-item".format(seq_index + 1) - self.q(css=seq_css).first.click() - # Click triggers an ajax event - self.wait_for_ajax() - - def _section_titles(self): - """ - Return a list of all section titles on the page. - """ - chapter_css = '.course-navigation .chapter .group-heading' - return self.q(css=chapter_css).map(lambda el: el.text.strip()).results - - 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). - """ - # Retrieve the subsection title for the section - # Add one to the list index to get the CSS index, which starts at one - subsection_css = ( - ".course-navigation .chapter-content-container:nth-of-type({0}) " - ".menu-item a p:nth-of-type(1)" - ).format(section_index) - - # If the element is visible, we can get its text directly - # Otherwise, we need to get the HTML - # It *would* make sense to always get the HTML, but unfortunately - # the open tab has some child tags that we don't want. - return self.q( - css=subsection_css - ).map( - lambda el: el.text.strip().split('\n')[0] if el.is_displayed() else el.get_attribute('innerHTML').strip() - ).results - - def _on_section_promise(self, section_title, subsection_title): - """ - Return a `Promise` that is fulfilled when the user is on - the correct section and subsection. - """ - desc = "currently at section '{0}' and subsection '{1}'".format(section_title, subsection_title) - return EmptyPromise( - lambda: self.is_on_section(section_title, subsection_title), desc - ) - - @unguarded - def is_on_section(self, section_title, subsection_title): - """ - Return a boolean indicating whether the user is on the section and subsection - with the specified titles. - - This assumes that the currently expanded section is the one we're on - That's true right after we click the section/subsection, but not true in general - (the user could go to a section, then expand another tab). - """ - current_section_list = self.q(css='.course-navigation .chapter.is-open .group-heading').text - current_subsection_list = self.q(css='.course-navigation .chapter-content-container .menu-item.active a p').text - - if len(current_section_list) == 0: - self.warning("Could not find the current section") - return False - - elif len(current_subsection_list) == 0: - self.warning("Could not find current subsection") - return False - - else: - return ( - current_section_list[0].strip() == section_title and - current_subsection_list[0].strip().split('\n')[0] == subsection_title - ) - - # Regular expression to remove HTML span tags from a string - REMOVE_SPAN_TAG_RE = re.compile(r'(.+)')] + return [part.strip() for part in self.q(css='.path .position').text[0].split('>')] def unit_title_visible(self): """ Check if unit title is visible """ @@ -301,6 +311,13 @@ class CoursewarePage(CoursePage): self.q(css='.bookmark-button').first.click() EmptyPromise(lambda: self.bookmark_button_state != previous_state, "Bookmark button toggled").fulfill() + # TODO: TNL-6546: Remove this helper function + def click_bookmarks_button(self): + """ Click on Bookmarks button """ + self.q(css='.bookmarks-list-button').first.click() + bookmarks_page = BookmarksPage(self.browser, self.course_id) + bookmarks_page.visit() + class CoursewareSequentialTabPage(CoursePage): """ @@ -319,3 +336,255 @@ class CoursewareSequentialTabPage(CoursePage): return the body of the sequential currently selected """ return self.q(css='#seq_content .xblock').text[0] + + +class CourseNavPage(PageObject): + """ + Handles navigation on the courseware pages, including sequence navigation and + breadcrumbs. + """ + + url = None + + def __init__(self, browser, parent_page): + super(CourseNavPage, self).__init__(browser) + self.parent_page = parent_page + # TODO: TNL-6546: Remove the following + self.unified_course_view = False + + def is_browser_on_page(self): + return self.parent_page.is_browser_on_page + + # TODO: TNL-6546: Remove method, outline no longer on courseware page + @property + def sections(self): + """ + Return a dictionary representation of sections and subsections. + + Example: + + { + 'Introduction': ['Course Overview'], + 'Week 1': ['Lesson 1', 'Lesson 2', 'Homework'] + 'Final Exam': ['Final Exam'] + } + + You can use these titles in `go_to_section` to navigate to the section. + """ + # Dict to store the result + nav_dict = dict() + + 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: + self.warning("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) + nav_dict[sec_title] = self._subsection_titles(sec_index + 1) + + return nav_dict + + @property + def sequence_items(self): + """ + Return a list of sequence items on the page. + Sequence items are one level below subsections in the course nav. + + Example return value: + ['Chemical Bonds Video', 'Practice Problems', 'Homework'] + """ + seq_css = 'ol#sequence-list>li>.nav-item>.sequence-tooltip' + return self.q(css=seq_css).map(self._clean_seq_titles).results + + # TODO: TNL-6546: Remove method, outline no longer on courseware page + def go_to_section(self, section_title, subsection_title): + """ + Go to the section in the courseware. + Every section must have at least one subsection, so specify + both the section and subsection title. + + Example: + go_to_section("Week 1", "Lesson 1") + """ + + # For test stability, disable JQuery animations (opening / closing menus) + self.browser.execute_script("jQuery.fx.off = true;") + + # Get the section by index + try: + sec_index = self._section_titles().index(section_title) + except ValueError: + self.warning("Could not find section '{0}'".format(section_title)) + return + + # Click the section to ensure it's open (no harm in clicking twice if it's already open) + # Add one to convert from list index to CSS index + section_css = '.course-navigation .chapter:nth-of-type({0})'.format(sec_index + 1) + self.q(css=section_css).first.click() + + # Get the subsection by index + try: + subsec_index = self._subsection_titles(sec_index + 1).index(subsection_title) + except ValueError: + msg = "Could not find subsection '{0}' in section '{1}'".format(subsection_title, section_title) + self.warning(msg) + return + + # Convert list indices (start at zero) to CSS indices (start at 1) + subsection_css = ( + ".course-navigation .chapter-content-container:nth-of-type({0}) " + ".menu-item:nth-of-type({1})" + ).format(sec_index + 1, subsec_index + 1) + + # Click the subsection and ensure that the page finishes reloading + self.q(css=subsection_css).first.click() + self._on_section_promise(section_title, subsection_title).fulfill() + + def go_to_vertical(self, vertical_title): + """ + Within a section/subsection, navigate to the vertical with `vertical_title`. + """ + + # Get the index of the item in the sequence + all_items = self.sequence_items + + try: + seq_index = all_items.index(vertical_title) + + except ValueError: + msg = "Could not find sequential '{0}'. Available sequentials: [{1}]".format( + vertical_title, ", ".join(all_items) + ) + self.warning(msg) + + else: + + # Click on the sequence item at the correct index + # Convert the list index (starts at 0) to a CSS index (starts at 1) + seq_css = "ol#sequence-list>li:nth-of-type({0})>.nav-item".format(seq_index + 1) + self.q(css=seq_css).first.click() + # Click triggers an ajax event + self.wait_for_ajax() + + # TODO: TNL-6546: Remove method, outline no longer on courseware page + def _section_titles(self): + """ + Return a list of all section titles on the page. + """ + chapter_css = '.course-navigation .chapter .group-heading' + return self.q(css=chapter_css).map(lambda el: el.text.strip()).results + + # TODO: TNL-6546: Remove method, outline no longer on courseware page + 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). + """ + # Retrieve the subsection title for the section + # Add one to the list index to get the CSS index, which starts at one + subsection_css = ( + ".course-navigation .chapter-content-container:nth-of-type({0}) " + ".menu-item a p:nth-of-type(1)" + ).format(section_index) + + # If the element is visible, we can get its text directly + # Otherwise, we need to get the HTML + # It *would* make sense to always get the HTML, but unfortunately + # the open tab has some child tags that we don't want. + return self.q( + css=subsection_css + ).map( + lambda el: el.text.strip().split('\n')[0] if el.is_displayed() else el.get_attribute('innerHTML').strip() + ).results + + # TODO: TNL-6546: Remove method, outline no longer on courseware page + def _on_section_promise(self, section_title, subsection_title): + """ + Return a `Promise` that is fulfilled when the user is on + the correct section and subsection. + """ + desc = "currently at section '{0}' and subsection '{1}'".format(section_title, subsection_title) + return EmptyPromise( + lambda: self.is_on_section(section_title, subsection_title), desc + ) + + def go_to_outline(self): + """ + Navigates using breadcrumb to the course outline on the course home page. + + Returns CourseHomePage page object. + """ + # To avoid circular dependency, importing inside the function + from common.test.acceptance.pages.lms.course_home import CourseHomePage + + course_home_page = CourseHomePage(self.browser, self.parent_page.course_id) + self.q(css='.path a').click() + course_home_page.wait_for_page() + return course_home_page + + @unguarded + def is_on_section(self, section_title, subsection_title): + """ + Return a boolean indicating whether the user is on the section and subsection + with the specified titles. + + """ + # TODO: TNL-6546: Remove if/else; always use unified_course_view version (if) + if self.unified_course_view: + # breadcrumb location of form: "SECTION_TITLE > SUBSECTION_TITLE > SEQUENTIAL_TITLE" + bread_crumb_current = self.q(css='.position').text + if len(bread_crumb_current) != 1: + self.warning("Could not find the current bread crumb with section and subsection.") + return False + + return bread_crumb_current[0].strip().startswith(section_title + ' > ' + subsection_title + ' > ') + + else: + # This assumes that the currently expanded section is the one we're on + # That's true right after we click the section/subsection, but not true in general + # (the user could go to a section, then expand another tab). + current_section_list = self.q(css='.course-navigation .chapter.is-open .group-heading').text + current_subsection_list = self.q(css='.course-navigation .chapter-content-container .menu-item.active a p').text + + if len(current_section_list) == 0: + self.warning("Could not find the current section") + return False + + elif len(current_subsection_list) == 0: + self.warning("Could not find current subsection") + return False + + else: + return ( + current_section_list[0].strip() == section_title and + current_subsection_list[0].strip().split('\n')[0] == subsection_title + ) + + # Regular expression to remove HTML span tags from a string + REMOVE_SPAN_TAG_RE = re.compile(r'(.+) - - -
-
-
-
-
diff --git a/lms/static/js/spec/courseware/bookmarks_list_view_spec.js b/lms/static/js/spec/courseware/bookmarks_list_view_spec.js deleted file mode 100644 index 095c96637a..0000000000 --- a/lms/static/js/spec/courseware/bookmarks_list_view_spec.js +++ /dev/null @@ -1,311 +0,0 @@ -define(['backbone', - 'jquery', - 'underscore', - 'logger', - 'URI', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/bookmarks/views/bookmarks_list_button', - 'js/bookmarks/views/bookmarks_list', - 'js/bookmarks/collections/bookmarks'], - function(Backbone, $, _, Logger, URI, AjaxHelpers, TemplateHelpers, BookmarksListButtonView, BookmarksListView, - BookmarksCollection) { - 'use strict'; - - describe('lms.courseware.bookmarks', function() { - var bookmarksButtonView; - - beforeEach(function() { - loadFixtures('js/fixtures/bookmarks/bookmarks.html'); - TemplateHelpers.installTemplates( - [ - 'templates/fields/message_banner', - 'templates/bookmarks/bookmarks-list' - ] - ); - spyOn(Logger, 'log').and.returnValue($.Deferred().resolve()); - jasmine.addMatchers({ - toHaveBeenCalledWithUrl: function() { - return { - compare: function(actual, expectedUrl) { - return { - pass: expectedUrl === actual.calls.mostRecent().args[0].currentTarget.pathname - }; - } - }; - } - }); - - bookmarksButtonView = new BookmarksListButtonView(); - }); - - var verifyRequestParams = function(requests, params) { - var urlParams = (new URI(requests[requests.length - 1].url)).query(true); - _.each(params, function(value, key) { - expect(urlParams[key]).toBe(value); - }); - }; - - var createBookmarksData = function(options) { - var data = { - count: options.count || 0, - num_pages: options.num_pages || 1, - current_page: options.current_page || 1, - start: options.start || 0, - results: [] - }; - - for (var i = 0; i < options.numBookmarksToCreate; i++) { - var bookmarkInfo = { - id: i, - display_name: 'UNIT_DISPLAY_NAME_' + i, - created: new Date().toISOString(), - course_id: 'COURSE_ID', - usage_id: 'UNIT_USAGE_ID_' + i, - block_type: 'vertical', - path: [ - {display_name: 'SECTION_DISAPLAY_NAME', usage_id: 'SECTION_USAGE_ID'}, - {display_name: 'SUBSECTION_DISAPLAY_NAME', usage_id: 'SUBSECTION_USAGE_ID'} - ] - }; - - data.results.push(bookmarkInfo); - } - - return data; - }; - - var createBookmarkUrl = function(courseId, usageId) { - return '/courses/' + courseId + '/jump_to/' + usageId; - }; - - var breadcrumbTrail = function(path, unitDisplayName) { - return _.pluck(path, 'display_name'). - concat([unitDisplayName]). - join(' - '); - }; - - var verifyBookmarkedData = function(view, expectedData) { - var courseId, usageId; - var bookmarks = view.$('.bookmarks-results-list-item'); - var results = expectedData.results; - - expect(bookmarks.length, results.length); - - for (var bookmark_index = 0; bookmark_index < results.length; bookmark_index++) { - courseId = results[bookmark_index].course_id; - usageId = results[bookmark_index].usage_id; - - expect(bookmarks[bookmark_index]).toHaveAttr('href', createBookmarkUrl(courseId, usageId)); - - expect($(bookmarks[bookmark_index]).data('bookmarkId')).toBe(bookmark_index); - expect($(bookmarks[bookmark_index]).data('componentType')).toBe('vertical'); - expect($(bookmarks[bookmark_index]).data('usageId')).toBe(usageId); - - expect($(bookmarks[bookmark_index]).find('.list-item-breadcrumbtrail').html().trim()). - toBe(breadcrumbTrail(results[bookmark_index].path, results[bookmark_index].display_name)); - - expect($(bookmarks[bookmark_index]).find('.list-item-date').text().trim()). - toBe('Bookmarked on ' + view.humanFriendlyDate(results[bookmark_index].created)); - } - }; - - var verifyPaginationInfo = function(requests, expectedData, currentPage, headerMessage) { - AjaxHelpers.respondWithJson(requests, expectedData); - verifyBookmarkedData(bookmarksButtonView.bookmarksListView, expectedData); - expect(bookmarksButtonView.bookmarksListView.$('.paging-footer span.current-page').text().trim()). - toBe(currentPage); - expect(bookmarksButtonView.bookmarksListView.$('.paging-header span').text().trim()). - toBe(headerMessage); - }; - - it('has correct behavior for bookmarks button', function() { - var requests = AjaxHelpers.requests(this); - - spyOn(bookmarksButtonView, 'toggleBookmarksListView').and.callThrough(); - - bookmarksButtonView.delegateEvents(); - - expect(bookmarksButtonView.$('.bookmarks-list-button')).toHaveAttr('aria-pressed', 'false'); - expect(bookmarksButtonView.$('.bookmarks-list-button')).toHaveClass('is-inactive'); - - bookmarksButtonView.$('.bookmarks-list-button').click(); - expect(bookmarksButtonView.toggleBookmarksListView).toHaveBeenCalled(); - expect(bookmarksButtonView.$('.bookmarks-list-button')).toHaveAttr('aria-pressed', 'true'); - expect(bookmarksButtonView.$('.bookmarks-list-button')).toHaveClass('is-active'); - AjaxHelpers.respondWithJson(requests, createBookmarksData({numBookmarksToCreate: 1})); - - bookmarksButtonView.$('.bookmarks-list-button').click(); - expect(bookmarksButtonView.$('.bookmarks-list-button')).toHaveAttr('aria-pressed', 'false'); - expect(bookmarksButtonView.$('.bookmarks-list-button')).toHaveClass('is-inactive'); - }); - - it('can correctly render an empty bookmarks list', function() { - var requests = AjaxHelpers.requests(this); - var expectedData = createBookmarksData({numBookmarksToCreate: 0}); - - bookmarksButtonView.$('.bookmarks-list-button').click(); - AjaxHelpers.respondWithJson(requests, expectedData); - - expect(bookmarksButtonView.bookmarksListView.$('.bookmarks-empty-header').text().trim()). - toBe('You have not bookmarked any courseware pages yet.'); - - var emptyListText = 'Use bookmarks to help you easily return to courseware pages. ' + - 'To bookmark a page, select Bookmark in the upper right corner of that page. ' + - 'To see a list of all your bookmarks, select Bookmarks in the upper left ' + - 'corner of any courseware page.'; - - expect(bookmarksButtonView.bookmarksListView.$('.bookmarks-empty-detail-title').text().trim()). - toBe(emptyListText); - - expect(bookmarksButtonView.bookmarksListView.$('.paging-header').length).toBe(0); - expect(bookmarksButtonView.bookmarksListView.$('.paging-footer').length).toBe(0); - }); - - it('has rendered bookmarked list correctly', function() { - var requests = AjaxHelpers.requests(this); - var expectedData = createBookmarksData({numBookmarksToCreate: 3}); - - bookmarksButtonView.$('.bookmarks-list-button').click(); - - verifyRequestParams( - requests, - {course_id: 'a/b/c', fields: 'display_name,path', page: '1', page_size: '10'} - ); - AjaxHelpers.respondWithJson(requests, expectedData); - - expect(bookmarksButtonView.bookmarksListView.$('.bookmarks-results-header').text().trim()). - toBe('My Bookmarks'); - - verifyBookmarkedData(bookmarksButtonView.bookmarksListView, expectedData); - - expect(bookmarksButtonView.bookmarksListView.$('.paging-header').length).toBe(1); - expect(bookmarksButtonView.bookmarksListView.$('.paging-footer').length).toBe(1); - }); - - it('calls bookmarks list render on page_changed event', function() { - var renderSpy = spyOn(BookmarksListView.prototype, 'render'); - var listView = new BookmarksListView({ - collection: new BookmarksCollection([], { - course_id: 'abc', - url: '/test-bookmarks/url/' - }) - }); - listView.collection.trigger('page_changed'); - expect(renderSpy).toHaveBeenCalled(); - }); - - it('can go to a page number', function() { - var requests = AjaxHelpers.requests(this); - var expectedData = createBookmarksData( - { - numBookmarksToCreate: 10, - count: 12, - num_pages: 2, - current_page: 1, - start: 0 - } - ); - - bookmarksButtonView.$('.bookmarks-list-button').click(); - AjaxHelpers.respondWithJson(requests, expectedData); - verifyBookmarkedData(bookmarksButtonView.bookmarksListView, expectedData); - - bookmarksButtonView.bookmarksListView.$('input#page-number-input').val('2'); - bookmarksButtonView.bookmarksListView.$('input#page-number-input').trigger('change'); - - expectedData = createBookmarksData( - { - numBookmarksToCreate: 2, - count: 12, - num_pages: 2, - current_page: 2, - start: 10 - } - ); - AjaxHelpers.respondWithJson(requests, expectedData); - verifyBookmarkedData(bookmarksButtonView.bookmarksListView, expectedData); - - expect(bookmarksButtonView.bookmarksListView.$('.paging-footer span.current-page').text().trim()). - toBe('2'); - expect(bookmarksButtonView.bookmarksListView.$('.paging-header span').text().trim()). - toBe('Showing 11-12 out of 12 total'); - }); - - it('can navigate forward and backward', function() { - var requests = AjaxHelpers.requests(this); - var expectedData = createBookmarksData( - { - numBookmarksToCreate: 10, - count: 15, - num_pages: 2, - current_page: 1, - start: 0 - } - ); - - bookmarksButtonView.$('.bookmarks-list-button').click(); - verifyPaginationInfo(requests, expectedData, '1', 'Showing 1-10 out of 15 total'); - verifyRequestParams( - requests, - {course_id: 'a/b/c', fields: 'display_name,path', page: '1', page_size: '10'} - ); - - bookmarksButtonView.bookmarksListView.$('.paging-footer .next-page-link').click(); - expectedData = createBookmarksData( - { - numBookmarksToCreate: 5, - count: 15, - num_pages: 2, - current_page: 2, - start: 10 - } - ); - verifyPaginationInfo(requests, expectedData, '2', 'Showing 11-15 out of 15 total'); - verifyRequestParams( - requests, - {course_id: 'a/b/c', fields: 'display_name,path', page: '2', page_size: '10'} - ); - - expectedData = createBookmarksData( - { - numBookmarksToCreate: 10, - count: 15, - num_pages: 2, - current_page: 1, - start: 0 - } - ); - bookmarksButtonView.bookmarksListView.$('.paging-footer .previous-page-link').click(); - verifyPaginationInfo(requests, expectedData, '1', 'Showing 1-10 out of 15 total'); - verifyRequestParams( - requests, - {course_id: 'a/b/c', fields: 'display_name,path', page: '1', page_size: '10'} - ); - }); - - it('can navigate to correct url', function() { - var requests = AjaxHelpers.requests(this); - spyOn(bookmarksButtonView.bookmarksListView, 'visitBookmark'); - - bookmarksButtonView.$('.bookmarks-list-button').click(); - AjaxHelpers.respondWithJson(requests, createBookmarksData({numBookmarksToCreate: 1})); - - bookmarksButtonView.bookmarksListView.$('.bookmarks-results-list-item').click(); - var url = bookmarksButtonView.bookmarksListView.$('.bookmarks-results-list-item').attr('href'); - expect(bookmarksButtonView.bookmarksListView.visitBookmark).toHaveBeenCalledWithUrl(url); - }); - - it('shows an error message for HTTP 500', function() { - var requests = AjaxHelpers.requests(this); - - bookmarksButtonView.$('.bookmarks-list-button').click(); - - AjaxHelpers.respondWithError(requests); - - expect(bookmarksButtonView.bookmarksListView.$('.bookmarks-results-header').text().trim()).not - .toBe('My Bookmarks'); - expect($('#error-message').text().trim()).toBe(bookmarksButtonView.bookmarksListView.errorMessage); - }); - }); - }); diff --git a/lms/static/karma_lms.conf.js b/lms/static/karma_lms.conf.js index 04deb9c5f0..5039c2a204 100644 --- a/lms/static/karma_lms.conf.js +++ b/lms/static/karma_lms.conf.js @@ -27,6 +27,8 @@ var options = { // Otherwise Istanbul which is used for coverage tracking will cause tests to not run. sourceFiles: [ {pattern: 'coffee/src/**/!(*spec).js'}, + {pattern: 'course_bookmarks/**/!(*spec).js'}, + {pattern: 'course_experience/js/**/!(*spec).js'}, {pattern: 'discussion/js/**/!(*spec).js'}, {pattern: 'js/**/!(*spec|djangojs).js'}, {pattern: 'lms/js/**/!(*spec).js'}, diff --git a/lms/static/lms/js/build.js b/lms/static/lms/js/build.js index 2a1b44b360..19e49f319a 100644 --- a/lms/static/lms/js/build.js +++ b/lms/static/lms/js/build.js @@ -18,6 +18,8 @@ * done. */ modules: getModulesList([ + 'course_bookmarks/js/course_bookmarks_factory', + 'course_experience/js/course_outline_factory', 'discussion/js/discussion_board_factory', 'discussion/js/discussion_profile_page_factory', 'js/api_admin/catalog_preview_factory', diff --git a/lms/static/lms/js/spec/main.js b/lms/static/lms/js/spec/main.js index 5759cc11b1..7adbc68f88 100644 --- a/lms/static/lms/js/spec/main.js +++ b/lms/static/lms/js/spec/main.js @@ -92,12 +92,6 @@ 'js/student_profile/views/learner_profile_factory': 'js/student_profile/views/learner_profile_factory', 'js/student_profile/views/learner_profile_view': 'js/student_profile/views/learner_profile_view', 'js/ccx/schedule': 'js/ccx/schedule', - - 'js/bookmarks/collections/bookmarks': 'js/bookmarks/collections/bookmarks', - 'js/bookmarks/models/bookmark': 'js/bookmarks/models/bookmark', - 'js/bookmarks/views/bookmarks_list_button': 'js/bookmarks/views/bookmarks_list_button', - 'js/bookmarks/views/bookmarks_list': 'js/bookmarks/views/bookmarks_list', - 'js/bookmarks/views/bookmark_button': 'js/bookmarks/views/bookmark_button', 'js/views/message_banner': 'js/views/message_banner', // edxnotes @@ -679,14 +673,16 @@ }); testFiles = [ + 'course_bookmarks/js/spec/bookmark_button_view_spec.js', + 'course_bookmarks/js/spec/bookmarks_list_view_spec.js', + 'course_bookmarks/js/spec/course_bookmarks_factory_spec.js', + 'course_experience/js/spec/course_outline_factory_spec.js', 'discussion/js/spec/discussion_board_factory_spec.js', 'discussion/js/spec/discussion_profile_page_factory_spec.js', 'discussion/js/spec/discussion_board_view_spec.js', 'discussion/js/spec/views/discussion_user_profile_view_spec.js', 'lms/js/spec/preview/preview_factory_spec.js', 'js/spec/api_admin/catalog_preview_spec.js', - 'js/spec/courseware/bookmark_button_view_spec.js', - 'js/spec/courseware/bookmarks_list_view_spec.js', 'js/spec/ccx/schedule_spec.js', 'js/spec/commerce/receipt_view_spec.js', 'js/spec/components/card/card_spec.js', diff --git a/lms/static/sass/_build-lms-v1.scss b/lms/static/sass/_build-lms-v1.scss index 4f3709d4ca..49a180ac3b 100644 --- a/lms/static/sass/_build-lms-v1.scss +++ b/lms/static/sass/_build-lms-v1.scss @@ -62,10 +62,12 @@ @import 'views/support'; @import 'views/oauth2'; @import "views/financial-assistance"; -@import 'views/bookmarks'; @import 'course/auto-cert'; @import 'views/api-access'; +// features +@import 'features/bookmarks-v1'; + // search @import 'search/search'; diff --git a/lms/static/sass/_build-lms-v2.scss b/lms/static/sass/_build-lms-v2.scss index e12692dd48..8521a6f460 100644 --- a/lms/static/sass/_build-lms-v2.scss +++ b/lms/static/sass/_build-lms-v2.scss @@ -19,4 +19,10 @@ @import 'shared-v2/modal'; @import 'shared-v2/help-tab'; +// Elements @import 'notifications'; +@import 'elements-v2/pagination'; + +// Features +@import 'features/bookmarks'; +@import 'features/course-outline'; diff --git a/lms/static/sass/elements-v2/_pagination.scss b/lms/static/sass/elements-v2/_pagination.scss new file mode 100644 index 0000000000..c85b4d83ca --- /dev/null +++ b/lms/static/sass/elements-v2/_pagination.scss @@ -0,0 +1,134 @@ +// Copied from elements/_pagination.scss + +.pagination { + @include clearfix(); + display: inline-block; + width: flex-grid(3, 12); + + &.pagination-compact { + @include text-align(right); + } + + &.pagination-full { + display: block; + width: flex-grid(4, 12); + margin: $baseline auto; + } + + .nav-item { + position: relative; + display: inline-block; + vertical-align: middle; + } + + .nav-link { + @include transition(all $tmg-f2 ease-in-out 0s); + display: block; + border: 0; + background-image: none; + background-color: transparent; + padding: ($baseline/2) ($baseline*0.75); + + &.previous { + margin-right: ($baseline/2); + } + + &.next { + margin-left: ($baseline/2); + } + + &:hover { + background-color: $lms-active-color; + background-image: none; + border-radius: 3px; + color: $white; + } + + &.is-disabled { + background-color: transparent; + color: $lms-gray; + pointer-events: none; + } + } + + .nav-label { + @extend .sr-only; + } + + .pagination-form, + .current-page, + .page-divider, + .total-pages { + display: inline-block; + } + + .current-page, + .page-number-input, + .total-pages { + width: ($baseline*2.5); + vertical-align: middle; + margin: 0 ($baseline*0.75); + padding: ($baseline/4); + text-align: center; + color: $lms-gray; + } + + .current-page { + position: absolute; + @include left(-($baseline/4)); + } + + .page-divider { + vertical-align: middle; + color: $lms-gray; + } + + .pagination-form { + position: relative; + z-index: 100; + + .page-number-label, + .submit-pagination-form { + @extend .sr-only; + } + + .page-number-input { + @include transition(all $tmg-f2 ease-in-out 0s); + border: 1px solid transparent; + border-bottom: 1px dotted $lms-gray; + border-radius: 0; + box-shadow: none; + background: none; + + &:hover { + background-color: $white; + opacity: 0.6; + } + + &:focus { + // borrowing the base input focus styles to match overall app + @include linear-gradient($yellow-l4, tint($yellow-l4, 90%)); + opacity: 1.0; + box-shadow: 0 0 3px $black inset; + background-color: $white; + border: 1px solid transparent; + border-radius: 3px; + } + } + } +} + +// styles for search/pagination metadata and sorting +.listing-tools { + color: $lms-gray; + + label { // override + color: inherit; + font-size: inherit; + cursor: auto; + } + + .listing-sort-select { + border: 0; + } +} diff --git a/lms/static/sass/features/_bookmarks-v1.scss b/lms/static/sass/features/_bookmarks-v1.scss new file mode 100644 index 0000000000..b5a3bbd21e --- /dev/null +++ b/lms/static/sass/features/_bookmarks-v1.scss @@ -0,0 +1,64 @@ +$bookmark-icon: "\f097"; // .fa-bookmark-o +$bookmarked-icon: "\f02e"; // .fa-bookmark + +// Rules for placing bookmarks and search button side by side +.wrapper-course-modes { + border-bottom: 1px solid $gray-l3; + padding: ($baseline/4); + + > div { + @include box-sizing(border-box); + display: inline-block; + } +} + +// Rules for Bookmarks Button +.courseware-bookmarks-button { + width: flex-grid(5); + vertical-align: top; + + .bookmarks-list-button { + @extend %ui-clear-button; + + // set styles + @extend %btn-pl-default-base; + @include font-size(13); + width: 100%; + padding: ($baseline/4) ($baseline/2); + + &:before { + content: $bookmarked-icon; + font-family: FontAwesome; + } + } +} + +// Rules for bookmark icon shown on each sequence nav item +.course-content { + + .bookmark-icon.bookmarked { + @include right($baseline / 4); + top: -3px; + position: absolute; + } + + // Rules for bookmark button's different styles + .bookmark-button-wrapper { + margin-bottom: ($baseline * 1.5); + } + + .bookmark-button { + + &:before { + content: $bookmark-icon; + font-family: FontAwesome; + } + + &.bookmarked { + &:before { + content: $bookmarked-icon; + } + } + + } +} diff --git a/lms/static/sass/features/_bookmarks.scss b/lms/static/sass/features/_bookmarks.scss new file mode 100644 index 0000000000..d155afabcd --- /dev/null +++ b/lms/static/sass/features/_bookmarks.scss @@ -0,0 +1,87 @@ +$bookmark-icon: "\f097"; // .fa-bookmark-o +$bookmarked-icon: "\f02e"; // .fa-bookmark + +// Rules for Bookmarks Results Header +.bookmarks-results-header { + letter-spacing: 0; + text-transform: none; + margin-bottom: ($baseline/2); +} + +// Rules for Bookmarks Results +.bookmarks-results-list { + padding-top: ($baseline/2); + + .bookmarks-results-list-item { + @include padding(0, $baseline, ($baseline/4), $baseline); + display: block; + border: 1px solid $lms-border-color; + margin-bottom: $baseline; + + &:hover { + border-color: palette(primary, base); + + .list-item-breadcrumbtrail { + color: palette(primary, base); + } + } + } + + .results-list-item-view { + @include float(right); + margin-top: $baseline; + } + + .list-item-date { + margin-top: ($baseline/4); + color: $lms-gray; + font-size: font-size(small); + } + + .bookmarks-results-list-item:before { + content: $bookmarked-icon; + position: relative; + top: -7px; + font-family: FontAwesome; + color: palette(primary, base); + } + + .list-item-content { + overflow: hidden; + } + + .list-item-left-section { + display: inline-block; + vertical-align: middle; + width: 90%; + } + + .list-item-right-section { + display: inline-block; + vertical-align: middle; + + .fa-arrow-right { + + @include rtl { + @include transform(rotate(180deg)); + } + } + } +} + +// Rules for empty bookmarks list +.bookmarks-empty { + margin-top: $baseline; + border: 1px solid $lms-border-color; + padding: $baseline; + background-color: $white; +} + +.bookmarks-empty-header { + @extend %t-title5; + margin-bottom: ($baseline/2); +} + +.bookmarks-empty-detail { + @extend %t-copy-sub1; +} diff --git a/lms/static/sass/features/_course-outline.scss b/lms/static/sass/features/_course-outline.scss new file mode 100644 index 0000000000..0fde15faf8 --- /dev/null +++ b/lms/static/sass/features/_course-outline.scss @@ -0,0 +1,50 @@ +.course-outline { + color: $lms-gray; + + .block-tree { + margin: 0; + list-style-type: none; + + .section { + margin: 0 (-1 * $baseline); + width: calc(100% + (2 * $baseline)); + padding: 0 ($baseline * 2); + + &:not(:first-child) { + border-top: 1px solid $lms-border-color; + + .section-name { + margin-top: $baseline; + } + } + + .section-name { + @include margin(0, 0, ($baseline / 2), ($baseline / 2)); + padding: 0; + font-weight: bold; + } + + .outline-item { + @include padding-left(0); + } + + ol.outline-item { + margin: 0 0 ($baseline / 2) 0; + + .subsection { + list-style-type: none; + + a.outline-item { + display: block; + padding: ($baseline / 2); + + &:hover { + background-color: palette(primary, x-back); + text-decoration: none; + } + } + } + } + } + } +} diff --git a/lms/static/sass/shared-v2/_layouts.scss b/lms/static/sass/shared-v2/_layouts.scss index 22f36dffac..5045a9210d 100644 --- a/lms/static/sass/shared-v2/_layouts.scss +++ b/lms/static/sass/shared-v2/_layouts.scss @@ -46,16 +46,6 @@ display: inline-block; } - .form-actions > * { - @include margin-left($baseline/2); - vertical-align: middle; - height: 34px; - } - - .form-actions > button { - height: 34px; - } - .form-actions > *:first-child { @include margin-left(0); } diff --git a/lms/static/sass/views/_bookmarks.scss b/lms/static/sass/views/_bookmarks.scss deleted file mode 100644 index b8ed6e5eb4..0000000000 --- a/lms/static/sass/views/_bookmarks.scss +++ /dev/null @@ -1,165 +0,0 @@ -$bookmark-icon: "\f097"; // .fa-bookmark-o -$bookmarked-icon: "\f02e"; // .fa-bookmark - -// Rules for placing bookmarks and search button side by side -.wrapper-course-modes { - border-bottom: 1px solid $gray-l3; - padding: ($baseline/4); - - > div { - @include box-sizing(border-box); - display: inline-block; - } -} - - -// Rules for Bookmarks Button -.courseware-bookmarks-button { - width: flex-grid(5); - vertical-align: top; - - .bookmarks-list-button { - @extend %ui-clear-button; - - // set styles - @extend %btn-pl-default-base; - @include font-size(13); - width: 100%; - padding: ($baseline/4) ($baseline/2); - - &:before { - content: $bookmarked-icon; - font-family: FontAwesome; - } - - &.is-active { - background-color: lighten($action-primary-bg,10%); - color: $white; - } - } -} - - -// Rules for Bookmarks Results Header -.bookmarks-results-header { - @extend %t-title4; - letter-spacing: 0; - text-transform: none; - margin-bottom: ($baseline/2); -} - -// Rules for Bookmarks Results -.bookmarks-results-list { - padding-top: ($baseline/2); - - .bookmarks-results-list-item { - @include padding(0, $baseline, ($baseline/4), $baseline); - display: block; - border: 1px solid $gray-l4; - margin-bottom: $baseline; - - &:hover { - border-color: $m-blue; - - .list-item-breadcrumbtrail { - color: $blue; - } - } - - .icon { - @extend %t-icon6; - } - } - - .results-list-item-view { - @include float(right); - margin-top: $baseline; - } - - .list-item-date { - @extend %t-copy-sub2; - margin-top: ($baseline/4); - color: $gray; - } - - .bookmarks-results-list-item:before { - content: $bookmarked-icon; - position: relative; - top: -7px; - font-family: FontAwesome; - color: $m-blue; - } - - .list-item-content { - overflow: hidden; - } - - .list-item-left-section { - display: inline-block; - vertical-align: middle; - width: 90%; - } - - .list-item-right-section { - display: inline-block; - vertical-align: middle; - - .fa-arrow-right { - - @include rtl { - @include transform(rotate(180deg)); - } - } - } -} - - -// Rules for empty bookmarks list -.bookmarks-empty { - margin-top: $baseline; - border: 1px solid $gray-l4; - padding: $baseline; - background-color: $gray-l6; -} - -.bookmarks-empty-header { - @extend %t-title5; - margin-bottom: ($baseline/2); -} - -.bookmarks-empty-detail { - @extend %t-copy-sub1; -} - - -// Rules for bookmark icon shown on each sequence nav item -.course-content { - - .bookmark-icon.bookmarked { - @include right($baseline / 4); - top: -3px; - position: absolute; - } - - - // Rules for bookmark button's different styles - .bookmark-button-wrapper { - margin-bottom: ($baseline * 1.5); - } - - .bookmark-button { - - &:before { - content: $bookmark-icon; - font-family: FontAwesome; - } - - &.bookmarked { - &:before { - content: $bookmarked-icon; - } - } - - } - -} diff --git a/lms/templates/bookmark_button.html b/lms/templates/bookmark_button.html index 6abaecca9f..21fe05420e 100644 --- a/lms/templates/bookmark_button.html +++ b/lms/templates/bookmark_button.html @@ -1,10 +1,15 @@ <%page expression_filter="h" args="bookmark_id, is_bookmarked" /> -<%! from django.utils.translation import ugettext as _ %> + +<%! +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ +%>
diff --git a/lms/templates/bookmarks/bookmarks-list.underscore b/lms/templates/bookmarks/bookmarks-list.underscore deleted file mode 100644 index bd3ca9ed2f..0000000000 --- a/lms/templates/bookmarks/bookmarks-list.underscore +++ /dev/null @@ -1,43 +0,0 @@ -
-

<%= gettext("My Bookmarks") %>

- -<% if (bookmarksCollection.length) { %> - -
- - - - - -<% } else {%> - -
-
- - <%= gettext("You have not bookmarked any courseware pages yet.") %> -
-
-
- - <%= gettext("Use bookmarks to help you easily return to courseware pages. To bookmark a page, select Bookmark in the upper right corner of that page. To see a list of all your bookmarks, select Bookmarks in the upper left corner of any courseware page.") %> - -
-
- -<% } %> diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index c82ca94ac2..128f9b6ab1 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -3,12 +3,15 @@ <%namespace name='static' file='/static_content.html'/> <%def name="online_help_token()"><% return "courseware" %> <%! -from django.utils.translation import ugettext as _ +import waffle + from django.conf import settings +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ from edxnotes.helpers import is_feature_enabled as is_edxnotes_enabled -from openedx.core.djangolib.markup import HTML from openedx.core.djangolib.js_utils import js_escaped_string +from openedx.core.djangolib.markup import HTML %> <% include_special_exams = settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) and (course.enable_proctored_exams or course.enable_timed_exams) @@ -27,7 +30,7 @@ from openedx.core.djangolib.js_utils import js_escaped_string <%block name="header_extras"> -% for template_name in ["image-modal"]: +% for template_name in ["image-modal", "sequence-breadcrumbs"]: @@ -115,10 +118,10 @@ ${HTML(fragment.foot_html())}
- % if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'): @@ -152,7 +155,10 @@ ${HTML(fragment.foot_html())} % endif
-
+
% if getattr(course, 'entrance_exam_enabled') and \ getattr(course, 'entrance_exam_minimum_score_pct') and \ entrance_exam_current_score is not UNDEFINED: diff --git a/lms/urls.py b/lms/urls.py index e5b6736bdd..0dedb36db9 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -601,10 +601,27 @@ urlpatterns += ( name='edxnotes_endpoints', ), + # Branding API url( r'^api/branding/v1/', include('branding.api_urls') ), + + # Course experience + url( + r'^courses/{}/course/'.format( + settings.COURSE_ID_PATTERN, + ), + include('openedx.features.course_experience.urls'), + ), + + # Course bookmarks + url( + r'^courses/{}/bookmarks/'.format( + settings.COURSE_ID_PATTERN, + ), + include('openedx.features.course_bookmarks.urls'), + ), ) if settings.FEATURES["ENABLE_TEAMS"]: diff --git a/openedx/core/djangoapps/plugin_api/views.py b/openedx/core/djangoapps/plugin_api/views.py index 668eea6caa..ba0b34796c 100644 --- a/openedx/core/djangoapps/plugin_api/views.py +++ b/openedx/core/djangoapps/plugin_api/views.py @@ -45,21 +45,18 @@ class EdxFragmentView(FragmentView): else: return settings.PIPELINE_JS[group]['source_filenames'] - @abstractmethod def vendor_js_dependencies(self): """ Returns list of the vendor JS files that this view depends on. """ return [] - @abstractmethod def js_dependencies(self): """ Returns list of the JavaScript files that this view depends on. """ return [] - @abstractmethod def css_dependencies(self): """ Returns list of the CSS files that this view depends on. diff --git a/openedx/features/README.rst b/openedx/features/README.rst new file mode 100644 index 0000000000..ce592b57f0 --- /dev/null +++ b/openedx/features/README.rst @@ -0,0 +1,6 @@ +Open EdX Features +----------------- + +This is the root package for Open edX features that extend the edX platform. +The intention is that these features would ideally live in an external +repository, but for now they live in edx-platform but are cleanly modularized. diff --git a/openedx/features/__init__.py b/openedx/features/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/course_bookmarks/__init__.py b/openedx/features/course_bookmarks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/static/js/fixtures/bookmarks/bookmark_button.html b/openedx/features/course_bookmarks/static/course_bookmarks/fixtures/bookmark_button.html similarity index 52% rename from lms/static/js/fixtures/bookmarks/bookmark_button.html rename to openedx/features/course_bookmarks/static/course_bookmarks/fixtures/bookmark_button.html index 1e536fdd14..760f661c30 100644 --- a/lms/static/js/fixtures/bookmarks/bookmark_button.html +++ b/openedx/features/course_bookmarks/static/course_bookmarks/fixtures/bookmark_button.html @@ -1,12 +1,11 @@ -
-
diff --git a/openedx/features/course_bookmarks/static/course_bookmarks/fixtures/bookmarks.html b/openedx/features/course_bookmarks/static/course_bookmarks/fixtures/bookmarks.html new file mode 100644 index 0000000000..e5906eeb84 --- /dev/null +++ b/openedx/features/course_bookmarks/static/course_bookmarks/fixtures/bookmarks.html @@ -0,0 +1,23 @@ +
+ +
+
+
+
+
+
+
+
diff --git a/lms/static/js/bookmarks/collections/bookmarks.js b/openedx/features/course_bookmarks/static/course_bookmarks/js/collections/bookmarks.js similarity index 90% rename from lms/static/js/bookmarks/collections/bookmarks.js rename to openedx/features/course_bookmarks/static/course_bookmarks/js/collections/bookmarks.js index ffedc9ef2a..65a40988ee 100644 --- a/lms/static/js/bookmarks/collections/bookmarks.js +++ b/openedx/features/course_bookmarks/static/course_bookmarks/js/collections/bookmarks.js @@ -3,7 +3,7 @@ define([ 'backbone', 'edx-ui-toolkit/js/pagination/paging-collection', - 'js/bookmarks/models/bookmark' + 'course_bookmarks/js/models/bookmark' ], function(Backbone, PagingCollection, BookmarkModel) { return PagingCollection.extend({ model: BookmarkModel, @@ -24,5 +24,5 @@ } }); }); -})(define || RequireJS.define); +}(define || RequireJS.define)); diff --git a/openedx/features/course_bookmarks/static/course_bookmarks/js/course_bookmarks_factory.js b/openedx/features/course_bookmarks/static/course_bookmarks/js/course_bookmarks_factory.js new file mode 100644 index 0000000000..f9aab1d77d --- /dev/null +++ b/openedx/features/course_bookmarks/static/course_bookmarks/js/course_bookmarks_factory.js @@ -0,0 +1,35 @@ +(function(define) { + 'use strict'; + + define( + [ + 'jquery', + 'js/views/message_banner', + 'course_bookmarks/js/collections/bookmarks', + 'course_bookmarks/js/views/bookmarks_list' + ], + function($, MessageBannerView, BookmarksCollection, BookmarksListView) { + return function(options) { + var courseId = options.courseId, + bookmarksApiUrl = options.bookmarksApiUrl, + bookmarksCollection = new BookmarksCollection([], + { + course_id: courseId, + url: bookmarksApiUrl + } + ); + var bookmarksView = new BookmarksListView( + { + $el: options.$el, + collection: bookmarksCollection, + loadingMessageView: new MessageBannerView({el: $('#loading-message')}), + errorMessageView: new MessageBannerView({el: $('#error-message')}) + } + ); + bookmarksView.render(); + bookmarksView.showBookmarks(); + return bookmarksView; + }; + } + ); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/bookmarks/models/bookmark.js b/openedx/features/course_bookmarks/static/course_bookmarks/js/models/bookmark.js similarity index 94% rename from lms/static/js/bookmarks/models/bookmark.js rename to openedx/features/course_bookmarks/static/course_bookmarks/js/models/bookmark.js index 3f0e7c6e17..25a8eeeeb1 100644 --- a/lms/static/js/bookmarks/models/bookmark.js +++ b/openedx/features/course_bookmarks/static/course_bookmarks/js/models/bookmark.js @@ -16,4 +16,4 @@ } }); }); -})(define || RequireJS.define); +}(define || RequireJS.define)); diff --git a/lms/static/js/spec/courseware/bookmark_button_view_spec.js b/openedx/features/course_bookmarks/static/course_bookmarks/js/spec/bookmark_button_view_spec.js similarity index 90% rename from lms/static/js/spec/courseware/bookmark_button_view_spec.js rename to openedx/features/course_bookmarks/static/course_bookmarks/js/spec/bookmark_button_view_spec.js index 179b157010..4a5615772c 100644 --- a/lms/static/js/spec/courseware/bookmark_button_view_spec.js +++ b/openedx/features/course_bookmarks/static/course_bookmarks/js/spec/bookmark_button_view_spec.js @@ -1,23 +1,24 @@ -define(['backbone', 'jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', 'js/bookmarks/views/bookmark_button' - ], +define([ + 'backbone', 'jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', + 'common/js/spec_helpers/template_helpers', 'course_bookmarks/js/views/bookmark_button' +], function(Backbone, $, _, AjaxHelpers, TemplateHelpers, BookmarkButtonView) { 'use strict'; - describe('bookmarks.button', function() { - var timerCallback; + describe('BookmarkButtonView', function() { + var createBookmarkButtonView, verifyBookmarkButtonState; var API_URL = 'bookmarks/api/v1/bookmarks/'; beforeEach(function() { - loadFixtures('js/fixtures/bookmarks/bookmark_button.html'); + loadFixtures('course_bookmarks/fixtures/bookmark_button.html'); TemplateHelpers.installTemplates( [ 'templates/fields/message_banner' ] ); - timerCallback = jasmine.createSpy('timerCallback'); + jasmine.createSpy('timerCallback'); jasmine.clock().install(); }); @@ -25,7 +26,7 @@ define(['backbone', 'jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helper jasmine.clock().uninstall(); }); - var createBookmarkButtonView = function(isBookmarked) { + createBookmarkButtonView = function(isBookmarked) { return new BookmarkButtonView({ el: '.bookmark-button', bookmarked: isBookmarked, @@ -35,7 +36,7 @@ define(['backbone', 'jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helper }); }; - var verifyBookmarkButtonState = function(view, bookmarked) { + verifyBookmarkButtonState = function(view, bookmarked) { if (bookmarked) { expect(view.$el).toHaveAttr('aria-pressed', 'true'); expect(view.$el).toHaveClass('bookmarked'); @@ -46,7 +47,7 @@ define(['backbone', 'jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helper expect(view.$el.data('bookmarkId')).toBe('bilbo,usage_1'); }; - it('rendered correctly ', function() { + it('rendered correctly', function() { var view = createBookmarkButtonView(false); verifyBookmarkButtonState(view, false); diff --git a/openedx/features/course_bookmarks/static/course_bookmarks/js/spec/bookmarks_list_view_spec.js b/openedx/features/course_bookmarks/static/course_bookmarks/js/spec/bookmarks_list_view_spec.js new file mode 100644 index 0000000000..3e2682166a --- /dev/null +++ b/openedx/features/course_bookmarks/static/course_bookmarks/js/spec/bookmarks_list_view_spec.js @@ -0,0 +1,262 @@ +define([ + 'backbone', + 'jquery', + 'underscore', + 'logger', + 'URI', + 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', + 'common/js/spec_helpers/template_helpers', + 'js/views/message_banner', + 'course_bookmarks/js/spec_helpers/bookmark_helpers', + 'course_bookmarks/js/views/bookmarks_list', + 'course_bookmarks/js/collections/bookmarks' +], + function(Backbone, $, _, Logger, URI, AjaxHelpers, TemplateHelpers, MessageBannerView, + BookmarkHelpers, BookmarksListView, BookmarksCollection) { + 'use strict'; + + describe('BookmarksListView', function() { + var createBookmarksView, verifyRequestParams; + + beforeEach(function() { + loadFixtures('course_bookmarks/fixtures/bookmarks.html'); + TemplateHelpers.installTemplates([ + 'templates/fields/message_banner' + ]); + spyOn(Logger, 'log').and.returnValue($.Deferred().resolve()); + jasmine.addMatchers({ + toHaveBeenCalledWithUrl: function() { + return { + compare: function(actual, expectedUrl) { + return { + pass: expectedUrl === actual.calls.mostRecent().args[0].currentTarget.pathname + }; + } + }; + } + }); + }); + + createBookmarksView = function() { + var bookmarksCollection = new BookmarksCollection( + [], + { + course_id: BookmarkHelpers.TEST_COURSE_ID, + url: BookmarkHelpers.TEST_API_URL + } + ); + var bookmarksView = new BookmarksListView({ + $el: $('.course-bookmarks'), + collection: bookmarksCollection, + loadingMessageView: new MessageBannerView({el: $('#loading-message')}), + errorMessageView: new MessageBannerView({el: $('#error-message')}) + }); + return bookmarksView; + }; + + verifyRequestParams = function(requests, params) { + var urlParams = (new URI(requests[requests.length - 1].url)).query(true); + _.each(params, function(value, key) { + expect(urlParams[key]).toBe(value); + }); + }; + + it('can correctly render an empty bookmarks list', function() { + var requests = AjaxHelpers.requests(this); + var bookmarksView = createBookmarksView(); + var expectedData = BookmarkHelpers.createBookmarksData({numBookmarksToCreate: 0}); + + bookmarksView.showBookmarks(); + AjaxHelpers.respondWithJson(requests, expectedData); + + expect(bookmarksView.$('.bookmarks-empty-header').text().trim()).toBe( + 'You have not bookmarked any courseware pages yet' + ); + + expect(bookmarksView.$('.bookmarks-empty-detail-title').text().trim()).toBe( + 'Use bookmarks to help you easily return to courseware pages. ' + + 'To bookmark a page, click "Bookmark this page" under the page title.' + ); + + expect(bookmarksView.$('.paging-header').length).toBe(0); + expect(bookmarksView.$('.paging-footer').length).toBe(0); + }); + + it('has rendered bookmarked list correctly', function() { + var requests = AjaxHelpers.requests(this); + var bookmarksView = createBookmarksView(); + var expectedData = BookmarkHelpers.createBookmarksData({numBookmarksToCreate: 3}); + + bookmarksView.showBookmarks(); + verifyRequestParams( + requests, + { + course_id: BookmarkHelpers.TEST_COURSE_ID, + fields: 'display_name,path', + page: '1', + page_size: '10' + } + ); + AjaxHelpers.respondWithJson(requests, expectedData); + + BookmarkHelpers.verifyBookmarkedData(bookmarksView, expectedData); + + expect(bookmarksView.$('.paging-header').length).toBe(1); + expect(bookmarksView.$('.paging-footer').length).toBe(1); + }); + + it('calls bookmarks list render on page_changed event', function() { + var renderSpy = spyOn(BookmarksListView.prototype, 'render'); + var listView = new BookmarksListView({ + collection: new BookmarksCollection([], { + course_id: 'abc', + url: '/test-bookmarks/url/' + }) + }); + listView.collection.trigger('page_changed'); + expect(renderSpy).toHaveBeenCalled(); + }); + + it('can go to a page number', function() { + var requests = AjaxHelpers.requests(this); + var expectedData = BookmarkHelpers.createBookmarksData( + { + numBookmarksToCreate: 10, + count: 12, + num_pages: 2, + current_page: 1, + start: 0 + } + ); + var bookmarksView = createBookmarksView(); + bookmarksView.showBookmarks(); + AjaxHelpers.respondWithJson(requests, expectedData); + BookmarkHelpers.verifyBookmarkedData(bookmarksView, expectedData); + + bookmarksView.$('input#page-number-input').val('2'); + bookmarksView.$('input#page-number-input').trigger('change'); + + expectedData = BookmarkHelpers.createBookmarksData( + { + numBookmarksToCreate: 2, + count: 12, + num_pages: 2, + current_page: 2, + start: 10 + } + ); + AjaxHelpers.respondWithJson(requests, expectedData); + BookmarkHelpers.verifyBookmarkedData(bookmarksView, expectedData); + + expect(bookmarksView.$('.paging-footer span.current-page').text().trim()).toBe('2'); + expect(bookmarksView.$('.paging-header span').text().trim()).toBe('Showing 11-12 out of 12 total'); + }); + + it('can navigate forward and backward', function() { + var requests = AjaxHelpers.requests(this); + var bookmarksView = createBookmarksView(); + var expectedData = BookmarkHelpers.createBookmarksData( + { + numBookmarksToCreate: 10, + count: 15, + num_pages: 2, + current_page: 1, + start: 0 + } + ); + bookmarksView.showBookmarks(); + BookmarkHelpers.verifyPaginationInfo( + requests, + bookmarksView, + expectedData, + '1', + 'Showing 1-10 out of 15 total' + ); + verifyRequestParams( + requests, + { + course_id: BookmarkHelpers.TEST_COURSE_ID, + fields: 'display_name,path', + page: '1', + page_size: '10' + } + ); + + bookmarksView.$('.paging-footer .next-page-link').click(); + expectedData = BookmarkHelpers.createBookmarksData( + { + numBookmarksToCreate: 5, + count: 15, + num_pages: 2, + current_page: 2, + start: 10 + } + ); + BookmarkHelpers.verifyPaginationInfo( + requests, + bookmarksView, + expectedData, + '2', + 'Showing 11-15 out of 15 total' + ); + verifyRequestParams( + requests, + { + course_id: BookmarkHelpers.TEST_COURSE_ID, + fields: 'display_name,path', + page: '2', + page_size: '10' + } + ); + + expectedData = BookmarkHelpers.createBookmarksData( + { + numBookmarksToCreate: 10, + count: 15, + num_pages: 2, + current_page: 1, + start: 0 + } + ); + bookmarksView.$('.paging-footer .previous-page-link').click(); + BookmarkHelpers.verifyPaginationInfo( + requests, + bookmarksView, + expectedData, + '1', + 'Showing 1-10 out of 15 total' + ); + verifyRequestParams( + requests, + { + course_id: BookmarkHelpers.TEST_COURSE_ID, + fields: 'display_name,path', + page: '1', + page_size: '10' + } + ); + }); + + it('can navigate to correct url', function() { + var requests = AjaxHelpers.requests(this); + var bookmarksView = createBookmarksView(); + var url; + spyOn(bookmarksView, 'visitBookmark'); + bookmarksView.showBookmarks(); + AjaxHelpers.respondWithJson(requests, BookmarkHelpers.createBookmarksData({numBookmarksToCreate: 1})); + + bookmarksView.$('.bookmarks-results-list-item').click(); + url = bookmarksView.$('.bookmarks-results-list-item').attr('href'); + expect(bookmarksView.visitBookmark).toHaveBeenCalledWithUrl(url); + }); + + it('shows an error message for HTTP 500', function() { + var requests = AjaxHelpers.requests(this); + var bookmarksView = createBookmarksView(); + bookmarksView.showBookmarks(); + AjaxHelpers.respondWithError(requests); + + expect($('#error-message').text().trim()).toBe(bookmarksView.errorMessage); + }); + }); + }); diff --git a/openedx/features/course_bookmarks/static/course_bookmarks/js/spec/course_bookmarks_factory_spec.js b/openedx/features/course_bookmarks/static/course_bookmarks/js/spec/course_bookmarks_factory_spec.js new file mode 100644 index 0000000000..9fd0894f41 --- /dev/null +++ b/openedx/features/course_bookmarks/static/course_bookmarks/js/spec/course_bookmarks_factory_spec.js @@ -0,0 +1,37 @@ +define([ + 'jquery', + 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', + 'course_bookmarks/js/spec_helpers/bookmark_helpers', + 'course_bookmarks/js/course_bookmarks_factory' +], + function($, AjaxHelpers, BookmarkHelpers, CourseBookmarksFactory) { + 'use strict'; + + describe('CourseBookmarksFactory', function() { + beforeEach(function() { + loadFixtures('course_bookmarks/fixtures/bookmarks.html'); + }); + + it('can render the initial bookmarks', function() { + var requests = AjaxHelpers.requests(this), + expectedData = BookmarkHelpers.createBookmarksData( + { + numBookmarksToCreate: 10, + count: 15, + num_pages: 2, + current_page: 1, + start: 0 + } + ), + bookmarksView; + bookmarksView = CourseBookmarksFactory({ + $el: $('.course-bookmarks'), + courseId: BookmarkHelpers.TEST_COURSE_ID, + bookmarksApiUrl: BookmarkHelpers.TEST_API_URL + }); + BookmarkHelpers.verifyPaginationInfo( + requests, bookmarksView, expectedData, '1', 'Showing 1-10 out of 15 total' + ); + }); + }); + }); diff --git a/openedx/features/course_bookmarks/static/course_bookmarks/js/spec_helpers/bookmark_helpers.js b/openedx/features/course_bookmarks/static/course_bookmarks/js/spec_helpers/bookmark_helpers.js new file mode 100644 index 0000000000..ff3a301dd9 --- /dev/null +++ b/openedx/features/course_bookmarks/static/course_bookmarks/js/spec_helpers/bookmark_helpers.js @@ -0,0 +1,93 @@ +define( + [ + 'underscore', + 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers' + ], + function(_, AjaxHelpers) { + 'use strict'; + + var TEST_COURSE_ID = 'course-v1:test-course'; + + var createBookmarksData = function(options) { + var data = { + count: options.count || 0, + num_pages: options.num_pages || 1, + current_page: options.current_page || 1, + start: options.start || 0, + results: [] + }, + i, bookmarkInfo; + + for (i = 0; i < options.numBookmarksToCreate; i++) { + bookmarkInfo = { + id: i, + display_name: 'UNIT_DISPLAY_NAME_' + i, + created: new Date().toISOString(), + course_id: 'COURSE_ID', + usage_id: 'UNIT_USAGE_ID_' + i, + block_type: 'vertical', + path: [ + {display_name: 'SECTION_DISPLAY_NAME', usage_id: 'SECTION_USAGE_ID'}, + {display_name: 'SUBSECTION_DISPLAY_NAME', usage_id: 'SUBSECTION_USAGE_ID'} + ] + }; + + data.results.push(bookmarkInfo); + } + + return data; + }; + + var createBookmarkUrl = function(courseId, usageId) { + return '/courses/' + courseId + '/jump_to/' + usageId; + }; + + var breadcrumbTrail = function(path, unitDisplayName) { + return _.pluck(path, 'display_name'). + concat([unitDisplayName]). + join(' - '); + }; + + var verifyBookmarkedData = function(view, expectedData) { + var courseId, usageId; + var bookmarks = view.$('.bookmarks-results-list-item'); + var results = expectedData.results; + var i, $bookmark; + + expect(bookmarks.length, results.length); + + for (i = 0; i < results.length; i++) { + $bookmark = $(bookmarks[i]); + courseId = results[i].course_id; + usageId = results[i].usage_id; + + expect(bookmarks[i]).toHaveAttr('href', createBookmarkUrl(courseId, usageId)); + + expect($bookmark.data('bookmarkId')).toBe(i); + expect($bookmark.data('componentType')).toBe('vertical'); + expect($bookmark.data('usageId')).toBe(usageId); + + expect($bookmark.find('.list-item-breadcrumbtrail').html().trim()) + .toBe(breadcrumbTrail(results[i].path, results[i].display_name)); + + expect($bookmark.find('.list-item-date').text().trim()) + .toBe('Bookmarked on ' + view.humanFriendlyDate(results[i].created)); + } + }; + + var verifyPaginationInfo = function(requests, view, expectedData, currentPage, headerMessage) { + AjaxHelpers.respondWithJson(requests, expectedData); + verifyBookmarkedData(view, expectedData); + expect(view.$('.paging-footer span.current-page').text().trim()).toBe(currentPage); + expect(view.$('.paging-header span').text().trim()).toBe(headerMessage); + }; + + return { + TEST_COURSE_ID: TEST_COURSE_ID, + TEST_API_URL: '/bookmarks/api', + createBookmarksData: createBookmarksData, + createBookmarkUrl: createBookmarkUrl, + verifyBookmarkedData: verifyBookmarkedData, + verifyPaginationInfo: verifyPaginationInfo + }; + }); diff --git a/lms/static/js/bookmarks/views/bookmark_button.js b/openedx/features/course_bookmarks/static/course_bookmarks/js/views/bookmark_button.js similarity index 91% rename from lms/static/js/bookmarks/views/bookmark_button.js rename to openedx/features/course_bookmarks/static/course_bookmarks/js/views/bookmark_button.js index 5c85ffdb12..d64ac41af5 100644 --- a/lms/static/js/bookmarks/views/bookmark_button.js +++ b/openedx/features/course_bookmarks/static/course_bookmarks/js/views/bookmark_button.js @@ -1,4 +1,4 @@ -(function(define, undefined) { +(function(define) { 'use strict'; define(['gettext', 'jquery', 'underscore', 'backbone', 'js/views/message_banner'], function(gettext, $, _, Backbone, MessageBannerView) { @@ -9,7 +9,7 @@ bookmarkedText: gettext('Bookmarked'), events: { - 'click': 'toggleBookmark' + click: 'toggleBookmark' }, showBannerInterval: 5000, // time in ms @@ -46,14 +46,14 @@ view.setBookmarkState(true); }, error: function(jqXHR) { + var response, userMessage; try { - var response = jqXHR.responseText ? JSON.parse(jqXHR.responseText) : ''; - var userMessage = response ? response.user_message : ''; + response = jqXHR.responseText ? JSON.parse(jqXHR.responseText) : ''; + userMessage = response ? response.user_message : ''; view.showError(userMessage); + } catch (err) { + view.showError(); } - catch (err) { - view.showError(); - } }, complete: function() { view.$el.prop('disabled', false); diff --git a/lms/static/js/bookmarks/views/bookmarks_list.js b/openedx/features/course_bookmarks/static/course_bookmarks/js/views/bookmarks_list.js similarity index 66% rename from lms/static/js/bookmarks/views/bookmarks_list.js rename to openedx/features/course_bookmarks/static/course_bookmarks/js/views/bookmarks_list.js index 18db4360a3..229c4bcb51 100644 --- a/lms/static/js/bookmarks/views/bookmarks_list.js +++ b/openedx/features/course_bookmarks/static/course_bookmarks/js/views/bookmarks_list.js @@ -1,11 +1,12 @@ -(function(define, undefined) { +(function(define) { 'use strict'; - define(['gettext', 'jquery', 'underscore', 'backbone', 'logger', 'moment', 'edx-ui-toolkit/js/utils/html-utils', - 'common/js/components/views/paging_header', 'common/js/components/views/paging_footer', - 'text!templates/bookmarks/bookmarks-list.underscore' - ], + define([ + 'gettext', 'jquery', 'underscore', 'backbone', 'logger', 'moment', 'edx-ui-toolkit/js/utils/html-utils', + 'common/js/components/views/paging_header', 'common/js/components/views/paging_footer', + 'text!course_bookmarks/templates/bookmarks-list.underscore' + ], function(gettext, $, _, Backbone, Logger, _moment, HtmlUtils, - PagingHeaderView, PagingFooterView, BookmarksListTemplate) { + PagingHeaderView, PagingFooterView, bookmarksListTemplate) { var moment = _moment || window.moment; return Backbone.View.extend({ @@ -15,7 +16,7 @@ coursewareResultsWrapperEl: '.courseware-results-wrapper', errorIcon: '', - loadingIcon: '', + loadingIcon: '', // eslint-disable-line max-len errorMessage: gettext('An error has occurred. Please try again.'), loadingMessage: gettext('Loading'), @@ -27,7 +28,7 @@ }, initialize: function(options) { - this.template = HtmlUtils.template(BookmarksListTemplate); + this.template = HtmlUtils.template(bookmarksListTemplate); this.loadingMessageView = options.loadingMessageView; this.errorMessageView = options.errorMessageView; this.langCode = $(this.el).data('langCode'); @@ -65,47 +66,39 @@ }, visitBookmark: function(event) { - var bookmarkedComponent = $(event.currentTarget); - var bookmark_id = bookmarkedComponent.data('bookmarkId'); - var component_usage_id = bookmarkedComponent.data('usageId'); - var component_type = bookmarkedComponent.data('componentType'); + var $bookmarkedComponent = $(event.currentTarget), + bookmarkId = $bookmarkedComponent.data('bookmarkId'), + componentUsageId = $bookmarkedComponent.data('usageId'), + componentType = $bookmarkedComponent.data('componentType'); Logger.log( - 'edx.bookmark.accessed', + 'edx.bookmark.accessed', { - bookmark_id: bookmark_id, - component_type: component_type, - component_usage_id: component_usage_id + bookmark_id: bookmarkId, + component_type: componentType, + component_usage_id: componentUsageId } - ).always(function() { - window.location.href = event.currentTarget.pathname; - }); + ).always(function() { + window.location.href = event.currentTarget.pathname; + }); }, - /** - * Convert ISO 8601 formatted date into human friendly format. e.g, `2014-05-23T14:00:00Z` to `May 23, 2014` - * @param {String} isoDate - ISO 8601 formatted date string. - */ + /** + * Convert ISO 8601 formatted date into human friendly format. + * + * e.g, `2014-05-23T14:00:00Z` to `May 23, 2014` + * + * @param {String} isoDate - ISO 8601 formatted date string. + */ humanFriendlyDate: function(isoDate) { moment.locale(this.langCode); return moment(isoDate).format('LL'); }, - areBookmarksVisible: function() { - return this.$('#my-bookmarks').is(':visible'); - }, - - hideBookmarks: function() { - this.$el.hide(); - $(this.coursewareResultsWrapperEl).hide(); - $(this.coursewareContentEl).css('display', 'table-cell'); - }, - showBookmarksContainer: function() { $(this.coursewareContentEl).hide(); - // Empty el if it's not empty to get the clean state. + // Empty el if it's not empty to get the clean state. this.$el.html(''); this.$el.show(); - $(this.coursewareResultsWrapperEl).css('display', 'table-cell'); }, showLoadingMessage: function() { diff --git a/openedx/features/course_bookmarks/static/course_bookmarks/templates/bookmarks-list.underscore b/openedx/features/course_bookmarks/static/course_bookmarks/templates/bookmarks-list.underscore new file mode 100644 index 0000000000..604a5831ec --- /dev/null +++ b/openedx/features/course_bookmarks/static/course_bookmarks/templates/bookmarks-list.underscore @@ -0,0 +1,54 @@ +
+ +<% if (bookmarksCollection.length) { %> + +
+ + + + + +<% } else {%> + +
+

+ + <%- gettext("You have not bookmarked any courseware pages yet") %> +
+

+
+ + <%- gettext('Use bookmarks to help you easily return to courseware pages. To bookmark a page, click "Bookmark this page" under the page title.') %> + +
+
+ +<% } %> diff --git a/openedx/features/course_bookmarks/templates/course_bookmarks/course-bookmarks-fragment.html b/openedx/features/course_bookmarks/templates/course_bookmarks/course-bookmarks-fragment.html new file mode 100644 index 0000000000..3dcf8fbe33 --- /dev/null +++ b/openedx/features/course_bookmarks/templates/course_bookmarks/course-bookmarks-fragment.html @@ -0,0 +1,11 @@ +## mako + +<%page expression_filter="h"/> + +<%namespace name='static' file='../static_content.html'/> + +
+
+
+
+
diff --git a/openedx/features/course_bookmarks/templates/course_bookmarks/course-bookmarks.html b/openedx/features/course_bookmarks/templates/course_bookmarks/course-bookmarks.html new file mode 100644 index 0000000000..30a0997c60 --- /dev/null +++ b/openedx/features/course_bookmarks/templates/course_bookmarks/course-bookmarks.html @@ -0,0 +1,59 @@ +## mako + +<%! main_css = "style-main-v2" %> + +<%page expression_filter="h"/> +<%inherit file="../main.html" /> +<%namespace name='static' file='../static_content.html'/> +<%def name="online_help_token()"><% return "courseware" %> +<%def name="course_name()"> +<% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %> + + +<%! +import json +from django.utils.translation import ugettext as _ +from django.template.defaultfilters import escapejs + +from django_comment_client.permissions import has_permission +from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string +from openedx.core.djangolib.markup import HTML +%> + +<%block name="bodyclass">course + +<%block name="pagetitle">${course_name()} + +<%include file="../courseware/course_navigation.html" args="active_page='courseware'" /> + +<%block name="head_extra"> +${HTML(bookmarks_fragment.head_html())} + + +<%block name="footer_extra"> +${HTML(bookmarks_fragment.foot_html())} + + +<%block name="content"> +
+ +
+ ${HTML(bookmarks_fragment.body_html())} +
+
+ diff --git a/openedx/features/course_bookmarks/templates/course_bookmarks/course_bookmarks_js.template b/openedx/features/course_bookmarks/templates/course_bookmarks/course_bookmarks_js.template new file mode 100644 index 0000000000..348670d3d0 --- /dev/null +++ b/openedx/features/course_bookmarks/templates/course_bookmarks/course_bookmarks_js.template @@ -0,0 +1,15 @@ +## mako + +<%! +from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string +%> + +(function (require, define) { + require(['course_bookmarks/js/course_bookmarks_factory'], function (CourseBookmarksFactory) { + CourseBookmarksFactory({ + $el: $(".course-bookmarks"), + courseId: '${unicode(course.id) | n, js_escaped_string}', + bookmarksApiUrl: '${bookmarks_api_url | n, js_escaped_string}', + }); + }); +}).call(this, require || RequireJS.require, define || RequireJS.define); diff --git a/openedx/features/course_bookmarks/urls.py b/openedx/features/course_bookmarks/urls.py new file mode 100644 index 0000000000..789579e27d --- /dev/null +++ b/openedx/features/course_bookmarks/urls.py @@ -0,0 +1,20 @@ +""" +Defines URLs for the course experience. +""" + +from django.conf.urls import url + +from views.course_bookmarks import CourseBookmarksView, CourseBookmarksFragmentView + +urlpatterns = [ + url( + r'^$', + CourseBookmarksView.as_view(), + name='openedx.course_bookmarks.home', + ), + url( + r'^bookmarks_fragment$', + CourseBookmarksFragmentView.as_view(), + name='openedx.course_bookmarks.course_bookmarks_fragment_view', + ), +] diff --git a/openedx/features/course_bookmarks/views/__init__.py b/openedx/features/course_bookmarks/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/course_bookmarks/views/course_bookmarks.py b/openedx/features/course_bookmarks/views/course_bookmarks.py new file mode 100644 index 0000000000..9715fede27 --- /dev/null +++ b/openedx/features/course_bookmarks/views/course_bookmarks.py @@ -0,0 +1,82 @@ +""" +Views to show a course's bookmarks. +""" + +from django.contrib.auth.decorators import login_required +from django.core.context_processors import csrf +from django.core.urlresolvers import reverse +from django.shortcuts import render_to_response +from django.template.loader import render_to_string +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_control +from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.generic import View + +from courseware.courses import get_course_with_access +from lms.djangoapps.courseware.tabs import CoursewareTab +from opaque_keys.edx.keys import CourseKey +from openedx.core.djangoapps.plugin_api.views import EdxFragmentView +from util.views import ensure_valid_course_key +from web_fragments.fragment import Fragment +from xmodule.modulestore.django import modulestore + + +class CourseBookmarksView(View): + """ + View showing the user's bookmarks for a course. + """ + @method_decorator(login_required) + @method_decorator(ensure_csrf_cookie) + @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True)) + @method_decorator(ensure_valid_course_key) + def get(self, request, course_id): + """ + Displays the user's bookmarks for the specified course. + + Arguments: + request: HTTP request + course_id (unicode): course id + """ + course_key = CourseKey.from_string(course_id) + course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) + course_url_name = CoursewareTab.main_course_url_name(request) + course_url = reverse(course_url_name, kwargs={'course_id': unicode(course.id)}) + + # Render the bookmarks list as a fragment + bookmarks_fragment = CourseBookmarksFragmentView().render_to_fragment(request, course_id=course_id) + + # Render the course bookmarks page + context = { + 'csrf': csrf(request)['csrf_token'], + 'course': course, + 'course_url': course_url, + 'bookmarks_fragment': bookmarks_fragment, + 'disable_courseware_js': True, + 'uses_pattern_library': True, + } + return render_to_response('course_bookmarks/course-bookmarks.html', context) + + +class CourseBookmarksFragmentView(EdxFragmentView): + """ + Fragment view that shows a user's bookmarks for a course. + """ + def render_to_fragment(self, request, course_id=None, **kwargs): + """ + Renders the user's course bookmarks as a fragment. + """ + course_key = CourseKey.from_string(course_id) + course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) + + context = { + 'csrf': csrf(request)['csrf_token'], + 'course': course, + 'bookmarks_api_url': reverse('bookmarks'), + 'language_preference': 'en', # TODO: + } + html = render_to_string('course_bookmarks/course-bookmarks-fragment.html', context) + inline_js = render_to_string('course_bookmarks/course_bookmarks_js.template', context) + fragment = Fragment(html) + self.add_fragment_resource_urls(fragment) + fragment.add_javascript(inline_js) + return fragment diff --git a/openedx/features/course_experience/__init__.py b/openedx/features/course_experience/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/course_experience/static/course_experience/fixtures/course-outline-fragment.html b/openedx/features/course_experience/static/course_experience/fixtures/course-outline-fragment.html new file mode 100644 index 0000000000..45e64be1f3 --- /dev/null +++ b/openedx/features/course_experience/static/course_experience/fixtures/course-outline-fragment.html @@ -0,0 +1,124 @@ +
+
    +
  1. +
    + + Introduction +
    +
      +
    1. + + Demo Course Overview + +
    2. +
    +
  2. +
  3. +
    + + Example Week 1: Getting Started +
    +
      +
    1. + + Lesson 1 - Getting Started + +
    2. +
    3. + + Homework - Question Styles + +
    4. +
    +
  4. +
  5. +
    + + Example Week 2: Get Interactive +
    +
      +
    1. + + Lesson 2 - Let's Get Interactive! + +
    2. +
    3. + + Homework - Labs and Demos + +
    4. +
    5. + + Homework - Essays + +
    6. +
    +
  6. +
  7. +
    + + Example Week 3: Be Social +
    +
      +
    1. + + Lesson 3 - Be Social + +
    2. +
    3. + + Homework - Find Your Study Buddy + +
    4. +
    5. + + More Ways to Connect + +
    6. +
    +
  8. +
  9. +
    + + About Exams and Certificates +
    +
      +
    1. + + edX Exams + +
    2. +
    +
  10. +
  11. +
    + + holding section +
    +
      +
    1. + + New Subsection + +
    2. +
    +
  12. +
+
diff --git a/openedx/features/course_experience/static/course_experience/js/course_outline_factory.js b/openedx/features/course_experience/static/course_experience/js/course_outline_factory.js new file mode 100644 index 0000000000..3f2d3643c5 --- /dev/null +++ b/openedx/features/course_experience/static/course_experience/js/course_outline_factory.js @@ -0,0 +1,29 @@ +(function(define) { + 'use strict'; + + define([ + 'jquery', + 'edx-ui-toolkit/js/utils/constants' + ], + function($, constants) { + return function(root) { + // In the future this factory could instantiate a Backbone view or React component that handles events + $(root).keydown(function(event) { + var $focusable = $('.outline-item.focusable'), + currentFocusIndex = $.inArray(event.target, $focusable); + + switch (event.keyCode) { // eslint-disable-line default-case + case constants.keyCodes.down: + event.preventDefault(); + $focusable.eq(Math.min(currentFocusIndex + 1, $focusable.length - 1)).focus(); + break; + case constants.keyCodes.up: + event.preventDefault(); + $focusable.eq(Math.max(currentFocusIndex - 1, 0)).focus(); + break; + } + }); + }; + } + ); +}).call(this, define || RequireJS.define); diff --git a/openedx/features/course_experience/static/course_experience/js/spec/course_outline_factory_spec.js b/openedx/features/course_experience/static/course_experience/js/spec/course_outline_factory_spec.js new file mode 100644 index 0000000000..ccea16b8f2 --- /dev/null +++ b/openedx/features/course_experience/static/course_experience/js/spec/course_outline_factory_spec.js @@ -0,0 +1,86 @@ +define([ + 'jquery', + 'edx-ui-toolkit/js/utils/constants', + 'course_experience/js/course_outline_factory' +], + function($, constants, CourseOutlineFactory) { + 'use strict'; + + describe('Course outline factory', function() { + describe('keyboard listener', function() { + var triggerKeyListener = function(current, destination, keyCode) { + current.focus(); + spyOn(destination, 'focus'); + + $('.block-tree').trigger($.Event('keydown', { + keyCode: keyCode, + target: current + })); + }; + + beforeEach(function() { + loadFixtures('course_experience/fixtures/course-outline-fragment.html'); + CourseOutlineFactory('.block-tree'); + }); + + describe('when the down arrow is pressed', function() { + it('moves focus from a subsection to the next subsection in the outline', function() { + var current = $('a.focusable:contains("Homework - Labs and Demos")')[0], + destination = $('a.focusable:contains("Homework - Essays")')[0]; + + triggerKeyListener(current, destination, constants.keyCodes.down); + + expect(destination.focus).toHaveBeenCalled(); + }); + + it('moves focus to the section list if at a section boundary', function() { + var current = $('li.focusable:contains("Example Week 3: Be Social")')[0], + destination = $('ol.focusable:contains("Lesson 3 - Be Social")')[0]; + + triggerKeyListener(current, destination, constants.keyCodes.down); + + expect(destination.focus).toHaveBeenCalled(); + }); + + it('moves focus to the next section if on the last subsection', function() { + var current = $('a.focusable:contains("Homework - Essays")')[0], + destination = $('li.focusable:contains("Example Week 3: Be Social")')[0]; + + triggerKeyListener(current, destination, constants.keyCodes.down); + + expect(destination.focus).toHaveBeenCalled(); + }); + }); + + describe('when the up arrow is pressed', function() { + it('moves focus from a subsection to the previous subsection in the outline', function() { + var current = $('a.focusable:contains("Homework - Essays")')[0], + destination = $('a.focusable:contains("Homework - Labs and Demos")')[0]; + + triggerKeyListener(current, destination, constants.keyCodes.up); + + expect(destination.focus).toHaveBeenCalled(); + }); + + it('moves focus to the section group if at the first subsection', function() { + var current = $('a.focusable:contains("Lesson 3 - Be Social")')[0], + destination = $('ol.focusable:contains("Lesson 3 - Be Social")')[0]; + + triggerKeyListener(current, destination, constants.keyCodes.up); + + expect(destination.focus).toHaveBeenCalled(); + }); + + it('moves focus last subsection of the previous section if at a section boundary', function() { + var current = $('li.focusable:contains("Example Week 3: Be Social")')[0], + destination = $('a.focusable:contains("Homework - Essays")')[0]; + + triggerKeyListener(current, destination, constants.keyCodes.up); + + expect(destination.focus).toHaveBeenCalled(); + }); + }); + }); + }); + } +); diff --git a/openedx/features/course_experience/templates/course_experience/course-home.html b/openedx/features/course_experience/templates/course_experience/course-home.html new file mode 100644 index 0000000000..ee1ba91ca6 --- /dev/null +++ b/openedx/features/course_experience/templates/course_experience/course-home.html @@ -0,0 +1,69 @@ +## mako + +<%! main_css = "style-main-v2" %> + +<%page expression_filter="h"/> +<%inherit file="../main.html" /> +<%namespace name='static' file='../static_content.html'/> +<%def name="online_help_token()"><% return "courseware" %> +<%def name="course_name()"> +<% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %> + + +<%! +import json +from django.utils.translation import ugettext as _ +from django.template.defaultfilters import escapejs +from django.core.urlresolvers import reverse + +from django_comment_client.permissions import has_permission +from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string +from openedx.core.djangolib.markup import HTML +%> + +<%block name="bodyclass">course + +<%block name="pagetitle">${course_name()} + +<%include file="../courseware/course_navigation.html" args="active_page='courseware'" /> + +<%block name="headextra"> +${HTML(outline_fragment.head_html())} + + +<%block name="js_extra"> +${HTML(outline_fragment.foot_html())} + + +<%block name="content"> +
+ +
+ ${HTML(outline_fragment.body_html())} +
+
+ 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.html new file mode 100644 index 0000000000..4606c0c3ec --- /dev/null +++ b/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html @@ -0,0 +1,44 @@ +## mako + +<%page expression_filter="h"/> + +<%namespace name='static' file='../static_content.html'/> + +<%! +from django.utils.translation import ugettext as _ +%> + +<%static:require_module_async module_name="course_experience/js/course_outline_factory" class_name="CourseOutlineFactory"> + CourseOutlineFactory('.block-tree'); + + +
+
    + % for section in blocks.get('children') or []: +
  1. +
    + ${ section['display_name'] } +
    +
      + % for subsection in section.get('children') or []: +
    1. + + ${ subsection['display_name'] } + +
    2. + % endfor +
    +
  2. + % endfor +
+
diff --git a/openedx/features/course_experience/tests/__init__.py b/openedx/features/course_experience/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/course_experience/tests/views/__init__.py b/openedx/features/course_experience/tests/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/course_experience/tests/views/test_course_outline.py b/openedx/features/course_experience/tests/views/test_course_outline.py new file mode 100644 index 0000000000..76a6cfa456 --- /dev/null +++ b/openedx/features/course_experience/tests/views/test_course_outline.py @@ -0,0 +1,72 @@ +""" +Tests for the Course Outline view and supporting views. +""" +from django.core.urlresolvers import reverse + +from student.models import CourseEnrollment +from student.tests.factories import UserFactory + +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + + +class TestCourseOutlinePage(SharedModuleStoreTestCase): + """ + Test the new course outline view. + """ + @classmethod + def setUpClass(cls): + """Set up the simplest course possible.""" + # setUpClassAndTestData() already calls setUpClass on SharedModuleStoreTestCase + # pylint: disable=super-method-not-called + with super(TestCourseOutlinePage, cls).setUpClassAndTestData(): + cls.courses = [] + course = CourseFactory.create() + with cls.store.bulk_operations(course.id): + chapter = ItemFactory.create(category='chapter', parent_location=course.location) + section = ItemFactory.create(category='sequential', parent_location=chapter.location) + ItemFactory.create(category='vertical', parent_location=section.location) + + cls.courses.append(course) + + course = CourseFactory.create() + with cls.store.bulk_operations(course.id): + chapter = ItemFactory.create(category='chapter', parent_location=course.location) + section = ItemFactory.create(category='sequential', parent_location=chapter.location) + section2 = ItemFactory.create(category='sequential', parent_location=chapter.location) + ItemFactory.create(category='vertical', parent_location=section.location) + ItemFactory.create(category='vertical', parent_location=section2.location) + + @classmethod + def setUpTestData(cls): + """Set up and enroll our fake user in the course.""" + cls.password = 'test' + cls.user = UserFactory(password=cls.password) + for course in cls.courses: + CourseEnrollment.enroll(cls.user, course.id) + + def setUp(self): + """ + Set up for the tests. + """ + super(TestCourseOutlinePage, self).setUp() + self.client.login(username=self.user.username, password=self.password) + + def test_render(self): + for course in self.courses: + url = reverse( + 'edx.course_experience.course_home', + kwargs={ + 'course_id': unicode(course.id), + } + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + response_content = response.content.decode("utf-8") + + for chapter in course.children: + self.assertIn(chapter.display_name, response_content) + for section in chapter.children: + self.assertIn(section.display_name, response_content) + for vertical in section.children: + self.assertNotIn(vertical.display_name, response_content) diff --git a/openedx/features/course_experience/urls.py b/openedx/features/course_experience/urls.py new file mode 100644 index 0000000000..3e25fd59ba --- /dev/null +++ b/openedx/features/course_experience/urls.py @@ -0,0 +1,21 @@ +""" +Defines URLs for the course experience. +""" + +from django.conf.urls import url + +from views.course_home import CourseHomeView +from views.course_outline import CourseOutlineFragmentView + +urlpatterns = [ + url( + r'^$', + CourseHomeView.as_view(), + name='edx.course_experience.course_home', + ), + url( + r'^outline_fragment$', + CourseOutlineFragmentView.as_view(), + name='edx.course_experience.course_outline_fragment_view', + ), +] diff --git a/openedx/features/course_experience/views/__init__.py b/openedx/features/course_experience/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/course_experience/views/course_home.py b/openedx/features/course_experience/views/course_home.py new file mode 100644 index 0000000000..343f245d26 --- /dev/null +++ b/openedx/features/course_experience/views/course_home.py @@ -0,0 +1,50 @@ +""" +Views for the course home page. +""" + +from django.contrib.auth.decorators import login_required +from django.core.context_processors import csrf +from django.shortcuts import render_to_response +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_control +from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.generic import View + +from courseware.courses import get_course_with_access +from opaque_keys.edx.keys import CourseKey +from util.views import ensure_valid_course_key + +from course_outline import CourseOutlineFragmentView + + +class CourseHomeView(View): + """ + The home page for a course. + """ + @method_decorator(login_required) + @method_decorator(ensure_csrf_cookie) + @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True)) + @method_decorator(ensure_valid_course_key) + def get(self, request, course_id): + """ + Displays the home page for the specified course. + + Arguments: + request: HTTP request + course_id (unicode): course id + """ + course_key = CourseKey.from_string(course_id) + course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) + + # Render the outline as a fragment + outline_fragment = CourseOutlineFragmentView().render_to_fragment(request, course_id=course_id) + + # Render the entire unified course view + context = { + 'csrf': csrf(request)['csrf_token'], + 'course': course, + 'outline_fragment': outline_fragment, + 'disable_courseware_js': True, + 'uses_pattern_library': True, + } + return render_to_response('course_experience/course-home.html', context) diff --git a/openedx/features/course_experience/views/course_outline.py b/openedx/features/course_experience/views/course_outline.py new file mode 100644 index 0000000000..855e40e37c --- /dev/null +++ b/openedx/features/course_experience/views/course_outline.py @@ -0,0 +1,61 @@ +""" +Views to show a course outline. +""" + +from django.core.context_processors import csrf +from django.template.loader import render_to_string + +from courseware.courses import get_course_with_access +from lms.djangoapps.course_api.blocks.api import get_blocks +from opaque_keys.edx.keys import CourseKey +from web_fragments.fragment import Fragment +from web_fragments.views import FragmentView +from xmodule.modulestore.django import modulestore + + +class CourseOutlineFragmentView(FragmentView): + """ + Course outline fragment to be shown in the unified course view. + """ + + def populate_children(self, block, all_blocks): + """ + For a passed block, replace each id in its children array with the full representation of that child, + which will be looked up by id in the passed all_blocks dict. + Recursively do the same replacement for children of those children. + """ + children = block.get('children') or [] + + for i in range(len(children)): + child_id = block['children'][i] + child_detail = self.populate_children(all_blocks[child_id], all_blocks) + block['children'][i] = child_detail + + return block + + def render_to_fragment(self, request, course_id=None, **kwargs): + """ + Renders the course outline as a fragment. + """ + course_key = CourseKey.from_string(course_id) + course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) + course_usage_key = modulestore().make_course_usage_key(course_key) + all_blocks = get_blocks( + request, + course_usage_key, + user=request.user, + nav_depth=3, + requested_fields=['children', 'display_name', 'type'], + block_types_filter=['course', 'chapter', 'sequential'] + ) + + course_block_tree = all_blocks['blocks'][all_blocks['root']] # Get the root of the block tree + + context = { + 'csrf': csrf(request)['csrf_token'], + 'course': course, + # Recurse through the block tree, fleshing out each child object + 'blocks': self.populate_children(course_block_tree, all_blocks['blocks']) + } + html = render_to_string('course_experience/course-outline-fragment.html', context) + return Fragment(html) diff --git a/pavelib/quality.py b/pavelib/quality.py index 7f1c12ceb4..8228ac8fc7 100644 --- a/pavelib/quality.py +++ b/pavelib/quality.py @@ -5,13 +5,20 @@ from paver.easy import sh, task, cmdopts, needs, BuildFailure import json import os import re +from string import join from openedx.core.djangolib.markup import HTML from .utils.envs import Env from .utils.timer import timed -ALL_SYSTEMS = 'lms,cms,common,openedx,pavelib' +ALL_SYSTEMS = [ + 'cms', + 'common', + 'lms', + 'openedx', + 'pavelib', +] def top_python_dirs(dirname): @@ -45,7 +52,7 @@ def find_fixme(options): Run pylint on system code, only looking for fixme items. """ num_fixme = 0 - systems = getattr(options, 'system', ALL_SYSTEMS).split(',') + systems = getattr(options, 'system', '').split(',') or ALL_SYSTEMS for system in systems: # Directory to put the pylint report in. @@ -93,7 +100,7 @@ def run_pylint(options): num_violations = 0 violations_limit = int(getattr(options, 'limit', -1)) errors = getattr(options, 'errors', False) - systems = getattr(options, 'system', ALL_SYSTEMS).split(',') + systems = getattr(options, 'system', '').split(',') or ALL_SYSTEMS # Make sure the metrics subdirectory exists Env.METRICS_DIR.makedirs_p() @@ -234,7 +241,7 @@ def run_complexity(): Uses radon to examine cyclomatic complexity. For additional details on radon, see http://radon.readthedocs.org/ """ - system_string = 'cms/ lms/ common/ openedx/' + system_string = join(ALL_SYSTEMS, '/ ') + '/' complexity_report_dir = (Env.REPORT_DIR / "complexity") complexity_report = complexity_report_dir / "python_complexity.log"