Commit 3cbbb8f3 by muzaffaryousaf

Add/Remove Bookmark button to each unit in LMS courseware.

TNL-1957
parent c9b87aa0
$sequence--border-color: #C8C8C8; $sequence--border-color: #C8C8C8;
$link-color: rgb(26, 161, 222);
// repeated extends - needed since LMS styling was referenced // repeated extends - needed since LMS styling was referenced
.block-link { .block-link {
border-left: 1px solid lighten($sequence--border-color, 10%); border-left: 1px solid lighten($sequence--border-color, 10%);
...@@ -36,7 +36,7 @@ $sequence--border-color: #C8C8C8; ...@@ -36,7 +36,7 @@ $sequence--border-color: #C8C8C8;
// TODO (cpennington): This doesn't work anymore. XModules aren't able to // TODO (cpennington): This doesn't work anymore. XModules aren't able to
// import from external sources. // import from external sources.
@extend .topbar; @extend .topbar;
margin: -4px 0 ($baseline*1.5); margin: -4px 0 $baseline;
position: relative; position: relative;
border-bottom: none; border-bottom: none;
z-index: 0; z-index: 0;
...@@ -119,6 +119,10 @@ $sequence--border-color: #C8C8C8; ...@@ -119,6 +119,10 @@ $sequence--border-color: #C8C8C8;
-webkit-font-smoothing: antialiased; // Clear up the lines on the icons -webkit-font-smoothing: antialiased; // Clear up the lines on the icons
} }
i.fa-bookmark {
color: $link-color;
}
&.inactive { &.inactive {
.icon { .icon {
...@@ -142,6 +146,10 @@ $sequence--border-color: #C8C8C8; ...@@ -142,6 +146,10 @@ $sequence--border-color: #C8C8C8;
.icon { .icon {
color: rgb(10, 10, 10); color: rgb(10, 10, 10);
} }
i.fa-bookmark {
color: $link-color;
}
} }
} }
...@@ -295,3 +303,4 @@ nav.sequence-bottom { ...@@ -295,3 +303,4 @@ nav.sequence-bottom {
outline: none; outline: none;
} }
} }
...@@ -18,6 +18,8 @@ class @Sequence ...@@ -18,6 +18,8 @@ class @Sequence
bind: -> bind: ->
@$('#sequence-list a').click @goto @$('#sequence-list a').click @goto
@el.on 'bookmark:add', @addBookmarkIconToActiveNavItem
@el.on 'bookmark:remove', @removeBookmarkIconFromActiveNavItem
initProgress: -> initProgress: ->
@progressTable = {} # "#problem_#{id}" -> progress @progressTable = {} # "#problem_#{id}" -> progress
...@@ -102,8 +104,9 @@ class @Sequence ...@@ -102,8 +104,9 @@ class @Sequence
@mark_active new_position @mark_active new_position
current_tab = @contents.eq(new_position - 1) current_tab = @contents.eq(new_position - 1)
@content_container.html(current_tab.text()).attr("aria-labelledby", current_tab.attr("aria-labelledby"))
bookmarked = if @el.find('.active .bookmark-icon').hasClass('bookmarked') then true else false
@content_container.html(current_tab.text()).attr("aria-labelledby", current_tab.attr("aria-labelledby")).data('bookmarked', bookmarked)
XBlock.initializeBlocks(@content_container, @requestToken) XBlock.initializeBlocks(@content_container, @requestToken)
window.update_schematics() # For embedded circuit simulator exercises in 6.002x window.update_schematics() # For embedded circuit simulator exercises in 6.002x
...@@ -116,6 +119,8 @@ class @Sequence ...@@ -116,6 +119,8 @@ class @Sequence
sequence_links = @content_container.find('a.seqnav') sequence_links = @content_container.find('a.seqnav')
sequence_links.click @goto sequence_links.click @goto
@el.find('.path').html(@el.find('.nav-item.active').data('path'))
@sr_container.focus(); @sr_container.focus();
# @$("a.active").blur() # @$("a.active").blur()
...@@ -180,3 +185,13 @@ class @Sequence ...@@ -180,3 +185,13 @@ class @Sequence
element.removeClass("inactive") element.removeClass("inactive")
.removeClass("visited") .removeClass("visited")
.addClass("active") .addClass("active")
addBookmarkIconToActiveNavItem: (event) =>
event.preventDefault()
@el.find('.nav-item.active .bookmark-icon').removeClass('is-hidden').addClass('bookmarked')
@el.find('.nav-item.active .bookmark-icon-sr').text(gettext('Bookmarked'))
removeBookmarkIconFromActiveNavItem: (event) =>
event.preventDefault()
@el.find('.nav-item.active .bookmark-icon').removeClass('bookmarked').addClass('is-hidden')
@el.find('.nav-item.active .bookmark-icon-sr').text('')
...@@ -316,6 +316,7 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): ...@@ -316,6 +316,7 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule):
fragment.add_content(self.system.render_template('vert_module.html', { fragment.add_content(self.system.render_template('vert_module.html', {
'items': contents, 'items': contents,
'xblock_context': context, 'xblock_context': context,
'show_bookmark_button': False,
})) }))
return fragment return fragment
......
/* JavaScript for Vertical Student View. */
window.VerticalStudentView = function (runtime, element) {
RequireJS.require(['js/bookmarks/views/bookmark_button'], function (BookmarkButton) {
var $element = $(element);
var $bookmarkButtonElement = $element.find('.bookmark-button');
return new BookmarkButton({
el: $bookmarkButtonElement,
bookmarkId: $bookmarkButtonElement.data('bookmarkId'),
usageId: $element.data('usageId'),
bookmarked: $element.parent('#seq_content').data('bookmarked'),
apiUrl: $(".courseware-bookmarks-button").data('bookmarksApiUrl')
});
});
};
...@@ -55,7 +55,6 @@ class SequenceFields(object): ...@@ -55,7 +55,6 @@ class SequenceFields(object):
scope=Scope.settings, scope=Scope.settings,
) )
class ProctoringFields(object): class ProctoringFields(object):
""" """
Fields that are specific to Proctored or Timed Exams Fields that are specific to Proctored or Timed Exams
...@@ -119,9 +118,12 @@ class ProctoringFields(object): ...@@ -119,9 +118,12 @@ class ProctoringFields(object):
@XBlock.wants('proctoring') @XBlock.wants('proctoring')
@XBlock.wants('credit') @XBlock.wants('credit')
class SequenceModule(SequenceFields, ProctoringFields, XModule): @XBlock.needs("user")
''' Layout module which lays out content in a temporal sequence @XBlock.needs("bookmarks")
''' class SequenceModule(SequenceFields, XModule):
"""
Layout module which lays out content in a temporal sequence
"""
js = { js = {
'coffee': [resource_string(__name__, 'js/src/sequence/display.coffee')], 'coffee': [resource_string(__name__, 'js/src/sequence/display.coffee')],
'js': [resource_string(__name__, 'js/src/sequence/display/jquery.sequence.js')], 'js': [resource_string(__name__, 'js/src/sequence/display/jquery.sequence.js')],
...@@ -182,7 +184,12 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): ...@@ -182,7 +184,12 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
contents = [] contents = []
fragment = Fragment() fragment = Fragment()
context = context or {}
bookmarks_service = self.runtime.service(self, "bookmarks")
context["username"] = self.runtime.service(self, "user").get_current_user().opt_attrs['edx-platform.username']
display_names = [self.get_parent().display_name or '', self.display_name or '']
# Is this sequential part of a timed or proctored exam? # Is this sequential part of a timed or proctored exam?
if self.is_time_limited: if self.is_time_limited:
view_html = self._time_limited_student_view(context) view_html = self._time_limited_student_view(context)
...@@ -194,6 +201,9 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): ...@@ -194,6 +201,9 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
return fragment return fragment
for child in self.get_display_items(): for child in self.get_display_items():
is_bookmarked = bookmarks_service.is_bookmarked(usage_key=child.scope_ids.usage_id)
context["bookmarked"] = is_bookmarked
progress = child.get_progress() progress = child.get_progress()
rendered_child = child.render(STUDENT_VIEW, context) rendered_child = child.render(STUDENT_VIEW, context)
fragment.add_frag_resources(rendered_child) fragment.add_frag_resources(rendered_child)
...@@ -209,6 +219,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): ...@@ -209,6 +219,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
'progress_detail': Progress.to_js_detail_str(progress), 'progress_detail': Progress.to_js_detail_str(progress),
'type': child.get_icon_class(), 'type': child.get_icon_class(),
'id': child.scope_ids.usage_id.to_deprecated_string(), 'id': child.scope_ids.usage_id.to_deprecated_string(),
'bookmarked': is_bookmarked,
'path': " > ".join(display_names + [child.display_name or '']),
} }
if childinfo['title'] == '': if childinfo['title'] == '':
childinfo['title'] = child.display_name_with_default childinfo['title'] = child.display_name_with_default
......
...@@ -37,18 +37,31 @@ class BaseVerticalBlockTest(XModuleXmlImportTest): ...@@ -37,18 +37,31 @@ class BaseVerticalBlockTest(XModuleXmlImportTest):
self.vertical = course_seq.get_children()[0] self.vertical = course_seq.get_children()[0]
self.vertical.xmodule_runtime = self.module_system self.vertical.xmodule_runtime = self.module_system
self.username = "bilbo"
self.default_context = {"bookmarked": False, "username": self.username}
class VerticalBlockTestCase(BaseVerticalBlockTest): class VerticalBlockTestCase(BaseVerticalBlockTest):
""" """
Tests for the VerticalBlock. Tests for the VerticalBlock.
""" """
def assert_bookmark_info_in(self, content):
"""
Assert content has all the bookmark info.
"""
self.assertIn('bookmark_id', content)
self.assertIn('{},{}'.format(self.username, unicode(self.vertical.location)), content)
self.assertIn('bookmarked', content)
self.assertIn('show_bookmark_button', content)
def test_render_student_view(self): def test_render_student_view(self):
""" """
Test the rendering of the student view. Test the rendering of the student view.
""" """
html = self.module_system.render(self.vertical, STUDENT_VIEW, {}).content html = self.module_system.render(self.vertical, STUDENT_VIEW, self.default_context).content
self.assertIn(self.test_html_1, html) self.assertIn(self.test_html_1, html)
self.assertIn(self.test_html_2, html) self.assertIn(self.test_html_2, html)
self.assert_bookmark_info_in(html)
def test_render_studio_view(self): def test_render_studio_view(self):
""" """
......
...@@ -54,7 +54,14 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse ...@@ -54,7 +54,14 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse
fragment.add_content(self.system.render_template('vert_module.html', { fragment.add_content(self.system.render_template('vert_module.html', {
'items': contents, 'items': contents,
'xblock_context': context, 'xblock_context': context,
'show_bookmark_button': True,
'bookmarked': child_context['bookmarked'],
'bookmark_id': "{},{}".format(child_context['username'], unicode(self.location))
})) }))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vertical_student_view.js'))
fragment.initialize_js('VerticalStudentView')
return fragment return fragment
def author_view(self, context): def author_view(self, context):
......
...@@ -7,7 +7,7 @@ from .course_page import CoursePage ...@@ -7,7 +7,7 @@ from .course_page import CoursePage
class BookmarksPage(CoursePage): class BookmarksPage(CoursePage):
""" """
Coursware Bookmarks Page. Courseware Bookmarks Page.
""" """
url = None url = None
url_path = "courseware/" url_path = "courseware/"
...@@ -23,10 +23,11 @@ class BookmarksPage(CoursePage): ...@@ -23,10 +23,11 @@ class BookmarksPage(CoursePage):
""" Check if bookmarks button is visible """ """ Check if bookmarks button is visible """
return self.q(css=self.BOOKMARKS_BUTTON_SELECTOR).visible return self.q(css=self.BOOKMARKS_BUTTON_SELECTOR).visible
def click_bookmarks_button(self): def click_bookmarks_button(self, wait_for_results=True):
""" Click on Bookmarks button """ """ Click on Bookmarks button """
self.q(css=self.BOOKMARKS_BUTTON_SELECTOR).first.click() self.q(css=self.BOOKMARKS_BUTTON_SELECTOR).first.click()
EmptyPromise(self.results_present, "Bookmarks results present").fulfill() if wait_for_results:
EmptyPromise(self.results_present, "Bookmarks results present").fulfill()
def results_present(self): def results_present(self):
""" Check if bookmarks results are present """ """ Check if bookmarks results are present """
...@@ -53,9 +54,9 @@ class BookmarksPage(CoursePage): ...@@ -53,9 +54,9 @@ class BookmarksPage(CoursePage):
breadcrumbs = self.q(css=self.BOOKMARKED_BREADCRUMBS).text breadcrumbs = self.q(css=self.BOOKMARKED_BREADCRUMBS).text
return [breadcrumb.replace('\n', '').split('-') for breadcrumb in breadcrumbs] return [breadcrumb.replace('\n', '').split('-') for breadcrumb in breadcrumbs]
def click_bookmark(self, index): def click_bookmarked_block(self, index):
""" """
Click on bookmark at index `index` Click on bookmarked block at index `index`
Arguments: Arguments:
index (int): bookmark index in the list index (int): bookmark index in the list
......
...@@ -193,13 +193,13 @@ class CourseNavPage(PageObject): ...@@ -193,13 +193,13 @@ class CourseNavPage(PageObject):
) )
# Regular expression to remove HTML span tags from a string # Regular expression to remove HTML span tags from a string
REMOVE_SPAN_TAG_RE = re.compile(r'<span.+/span>') REMOVE_SPAN_TAG_RE = re.compile(r'</span>(.+)<span')
def _clean_seq_titles(self, element): def _clean_seq_titles(self, element):
""" """
Clean HTML of sequence titles, stripping out span tags and returning the first line. Clean HTML of sequence titles, stripping out span tags and returning the first line.
""" """
return self.REMOVE_SPAN_TAG_RE.sub('', element.get_attribute('innerHTML')).strip().split('\n')[0] return self.REMOVE_SPAN_TAG_RE.search(element.get_attribute('innerHTML')).groups()[0].strip()
def go_to_sequential_position(self, sequential_position): def go_to_sequential_position(self, sequential_position):
""" """
......
...@@ -3,6 +3,7 @@ Courseware page. ...@@ -3,6 +3,7 @@ Courseware page.
""" """
from .course_page import CoursePage from .course_page import CoursePage
from bok_choy.promise import EmptyPromise
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
...@@ -177,6 +178,32 @@ class CoursewarePage(CoursePage): ...@@ -177,6 +178,32 @@ class CoursewarePage(CoursePage):
attribute_value = lambda el: el.get_attribute('data-id') attribute_value = lambda el: el.get_attribute('data-id')
return self.q(css='#sequence-list a').filter(get_active).map(attribute_value).results[0] return self.q(css='#sequence-list a').filter(get_active).map(attribute_value).results[0]
@property
def breadcrumb(self):
""" Return the course tree breadcrumb shown above the sequential bar """
return [part.strip() for part in self.q(css='.path').text[0].split('>')]
def bookmark_button_visible(self):
""" Check if bookmark button is visible """
EmptyPromise(lambda: self.q(css='.bookmark-button').visible, "Bookmark button visible").fulfill()
return True
@property
def bookmark_button_state(self):
""" Return `bookmarked` if button is in bookmarked state else '' """
return 'bookmarked' if self.q(css='.bookmark-button.bookmarked').present else ''
@property
def bookmark_icon_visible(self):
""" Check if bookmark icon is visible on active sequence nav item """
return self.q(css='.active .bookmark-icon').visible
def click_bookmark_unit_button(self):
""" Bookmark a unit by clicking on Bookmark button """
previous_state = self.bookmark_button_state
self.q(css='.bookmark-button').first.click()
EmptyPromise(lambda: self.bookmark_button_state != previous_state, "Bookmark button toggled").fulfill()
class CoursewareSequentialTabPage(CoursePage): class CoursewareSequentialTabPage(CoursePage):
""" """
......
...@@ -2,17 +2,15 @@ ...@@ -2,17 +2,15 @@
""" """
End-to-end tests for the courseware unit bookmarks. End-to-end tests for the courseware unit bookmarks.
""" """
import json
import requests
from ...pages.studio.auto_auth import AutoAuthPage from ...pages.studio.auto_auth import AutoAuthPage
from ...pages.lms.bookmarks import BookmarksPage from ...pages.lms.bookmarks import BookmarksPage
from ...pages.lms.courseware import CoursewarePage from ...pages.lms.courseware import CoursewarePage
from ...pages.lms.course_nav import CourseNavPage
from ...pages.studio.overview import CourseOutlinePage from ...pages.studio.overview import CourseOutlinePage
from ...pages.common.logout import LogoutPage from ...pages.common.logout import LogoutPage
from ...fixtures.course import CourseFixture, XBlockFixtureDesc from ...fixtures.course import CourseFixture, XBlockFixtureDesc
from ...fixtures import LMS_BASE_URL
from ..helpers import EventsTestMixin, UniqueCourseTest, is_404_page from ..helpers import EventsTestMixin, UniqueCourseTest, is_404_page
...@@ -22,30 +20,29 @@ class BookmarksTestMixin(EventsTestMixin, UniqueCourseTest): ...@@ -22,30 +20,29 @@ class BookmarksTestMixin(EventsTestMixin, UniqueCourseTest):
""" """
USERNAME = "STUDENT" USERNAME = "STUDENT"
EMAIL = "student@example.com" EMAIL = "student@example.com"
COURSE_TREE_INFO = [
['TestSection1', 'TestSubsection1', 'TestProblem1'],
['TestSection2', 'TestSubsection2', 'TestProblem2']
]
def create_course_fixture(self): def create_course_fixture(self, num_chapters):
""" Create course fixture """ """
Create course fixture
Arguments:
num_chapters: number of chapters to create
"""
self.course_fixture = CourseFixture( # pylint: disable=attribute-defined-outside-init self.course_fixture = CourseFixture( # pylint: disable=attribute-defined-outside-init
self.course_info['org'], self.course_info['number'], self.course_info['org'], self.course_info['number'],
self.course_info['run'], self.course_info['display_name'] self.course_info['run'], self.course_info['display_name']
) )
self.course_fixture.add_children( xblocks = []
XBlockFixtureDesc('chapter', self.COURSE_TREE_INFO[0][0]).add_children( for index in range(num_chapters):
XBlockFixtureDesc('sequential', self.COURSE_TREE_INFO[0][1]).add_children( xblocks += [
XBlockFixtureDesc('problem', self.COURSE_TREE_INFO[0][2]) XBlockFixtureDesc('chapter', 'TestSection{}'.format(index)).add_children(
XBlockFixtureDesc('sequential', 'TestSubsection{}'.format(index)).add_children(
XBlockFixtureDesc('vertical', 'TestVertical{}'.format(index))
)
) )
), ]
XBlockFixtureDesc('chapter', self.COURSE_TREE_INFO[1][0]).add_children( self.course_fixture.add_children(*xblocks).install()
XBlockFixtureDesc('sequential', self.COURSE_TREE_INFO[1][1]).add_children(
XBlockFixtureDesc('problem', self.COURSE_TREE_INFO[1][2])
)
)
).install()
class BookmarksTest(BookmarksTestMixin): class BookmarksTest(BookmarksTestMixin):
...@@ -66,35 +63,64 @@ class BookmarksTest(BookmarksTestMixin): ...@@ -66,35 +63,64 @@ class BookmarksTest(BookmarksTestMixin):
self.course_info['run'] self.course_info['run']
) )
self.create_course_fixture() self.courseware_page = CoursewarePage(self.browser, self.course_id)
self.bookmarks_page = BookmarksPage(self.browser, self.course_id)
self.course_nav = CourseNavPage(self.browser)
def _test_setup(self, num_chapters=2):
"""
Setup test settings.
Arguments:
num_chapters: number of chapters to create in course
"""
self.create_course_fixture(num_chapters)
# Auto-auth register for the course. # Auto-auth register for the course.
AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL, course_id=self.course_id).visit() AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL, course_id=self.course_id).visit()
self.courseware_page = CoursewarePage(self.browser, self.course_id)
self.courseware_page.visit() self.courseware_page.visit()
self.bookmarks = BookmarksPage(self.browser, self.course_id)
# Use auto-auth to retrieve the session for a logged in user def _bookmark_unit(self, index):
self.session = requests.Session() """
response = self.session.get(LMS_BASE_URL + "/auto_auth?username=STUDENT&email=student@example.com") Bookmark a unit
self.assertTrue(response.ok, "Failed to get session info")
Arguments:
index: unit index to bookmark
"""
self.course_nav.go_to_section('TestSection{}'.format(index), 'TestSubsection{}'.format(index))
self.courseware_page.click_bookmark_unit_button()
def _bookmark_units(self, num_units):
"""
Bookmark first `num_units` units by visiting them
def _bookmark_unit(self, course_id, usage_id): Arguments:
""" Bookmark a single unit """ num_units(int): Number of units to bookmarks
csrftoken = self.session.cookies['csrftoken'] """
headers = {'Content-type': 'application/json', "X-CSRFToken": csrftoken} for index in range(num_units):
url = LMS_BASE_URL + "/api/bookmarks/v0/bookmarks/?course_id=" + course_id + '&fields=path' self._bookmark_unit(index)
data = json.dumps({'usage_id': usage_id})
def _breadcrumb(self, num_units):
"""
Creates breadcrumbs for the first `num_units`
response = self.session.post(url, data=data, headers=headers, cookies=self.session.cookies) Arguments:
response = json.loads(response.text) num_units(int): Number of units for which we want to create breadcrumbs
self.assertTrue(response['usage_id'] == usage_id, "Failed to bookmark unit")
def _bookmarks_blocks(self, xblocks): Returns:
""" Bookmark all units in a course """ list of breadcrumbs
for xblock in xblocks: """
self._bookmark_unit(self.course_id, usage_id=xblock.locator) breadcrumbs = []
for index in range(num_units):
breadcrumbs.append(
[
'TestSection{}'.format(index),
'TestSubsection{}'.format(index),
'TestVertical{}'.format(index)
]
)
return breadcrumbs
def _delete_section(self, index): def _delete_section(self, index):
""" Delete a section at index `index` """ """ Delete a section at index `index` """
...@@ -119,6 +145,39 @@ class BookmarksTest(BookmarksTestMixin): ...@@ -119,6 +145,39 @@ class BookmarksTest(BookmarksTestMixin):
self.courseware_page.visit() self.courseware_page.visit()
self.courseware_page.wait_for_page() self.courseware_page.wait_for_page()
def _toggle_bookmark_and_verify(self, bookmark_icon_state, bookmark_button_state, bookmarked_count):
"""
Bookmark/Un-Bookmark a unit and then verify
"""
self.assertTrue(self.courseware_page.bookmark_button_visible)
self.courseware_page.click_bookmark_unit_button()
self.assertEqual(self.courseware_page.bookmark_icon_visible, bookmark_icon_state)
self.assertEqual(self.courseware_page.bookmark_button_state, bookmark_button_state)
self.bookmarks_page.click_bookmarks_button()
self.assertEqual(self.bookmarks_page.count(), bookmarked_count)
def test_bookmark_button(self):
"""
Scenario: Bookmark unit button toggles correctly
Given that I am a registered user
And I visit my courseware page
For first 2 units
I visit the unit
And I can see the Bookmark button
When I click on Bookmark button
Then unit should be bookmarked
Then I click again on the bookmark button
And I should see a unit un-bookmarked
"""
self._test_setup()
for index in range(2):
self.course_nav.go_to_section('TestSection{}'.format(index), 'TestSubsection{}'.format(index))
self._toggle_bookmark_and_verify(True, 'bookmarked', 1)
self.bookmarks_page.click_bookmarks_button(False)
self._toggle_bookmark_and_verify(False, '', 0)
def test_empty_bookmarks_list(self): def test_empty_bookmarks_list(self):
""" """
Scenario: An empty bookmarks list is shown if there are no bookmarked units. Scenario: An empty bookmarks list is shown if there are no bookmarked units.
...@@ -130,15 +189,16 @@ class BookmarksTest(BookmarksTestMixin): ...@@ -130,15 +189,16 @@ class BookmarksTest(BookmarksTestMixin):
Then I should see an empty bookmarks list Then I should see an empty bookmarks list
And empty bookmarks list content is correct And empty bookmarks list content is correct
""" """
self.assertTrue(self.bookmarks.bookmarks_button_visible()) self._test_setup()
self.bookmarks.click_bookmarks_button() self.assertTrue(self.bookmarks_page.bookmarks_button_visible())
self.assertEqual(self.bookmarks.results_header_text(), 'MY BOOKMARKS') self.bookmarks_page.click_bookmarks_button()
self.assertEqual(self.bookmarks.empty_header_text(), 'You have not bookmarked any courseware pages yet.') self.assertEqual(self.bookmarks_page.results_header_text(), 'MY BOOKMARKS')
self.assertEqual(self.bookmarks_page.empty_header_text(), 'You have not bookmarked any courseware pages yet.')
empty_list_text = ("Use bookmarks to help you easily return to courseware pages. To bookmark a page, " empty_list_text = ("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 " "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.") "bookmarks, select Bookmarks in the upper left corner of any courseware page.")
self.assertEqual(self.bookmarks.empty_list_text(), empty_list_text) self.assertEqual(self.bookmarks_page.empty_list_text(), empty_list_text)
def test_bookmarks_list(self): def test_bookmarks_list(self):
""" """
...@@ -160,27 +220,30 @@ class BookmarksTest(BookmarksTestMixin): ...@@ -160,27 +220,30 @@ class BookmarksTest(BookmarksTestMixin):
# discarded by the current version of MySQL we are using due to the # discarded by the current version of MySQL we are using due to the
# lack of support. Due to which order of bookmarked units will be # lack of support. Due to which order of bookmarked units will be
# incorrect. # incorrect.
xblocks = self.course_fixture.get_nested_xblocks(category="problem") self._test_setup()
self._bookmarks_blocks(xblocks) self._bookmark_units(2)
self.bookmarks.click_bookmarks_button() self.bookmarks_page.click_bookmarks_button()
self.assertTrue(self.bookmarks.results_present()) self.assertTrue(self.bookmarks_page.results_present())
self.assertEqual(self.bookmarks.results_header_text(), 'MY BOOKMARKS') self.assertEqual(self.bookmarks_page.results_header_text(), 'MY BOOKMARKS')
self.assertEqual(self.bookmarks.count(), 2) self.assertEqual(self.bookmarks_page.count(), 2)
bookmarked_breadcrumbs = self.bookmarks.breadcrumbs() bookmarked_breadcrumbs = self.bookmarks_page.breadcrumbs()
# Verify bookmarked breadcrumbs # Verify bookmarked breadcrumbs
self.assertItemsEqual(bookmarked_breadcrumbs, self.COURSE_TREE_INFO) breadcrumbs = self._breadcrumb(2)
self.assertItemsEqual(bookmarked_breadcrumbs, breadcrumbs)
# get usage ids for units
xblocks = self.course_fixture.get_nested_xblocks(category="vertical")
xblock_usage_ids = [xblock.locator for xblock in xblocks] xblock_usage_ids = [xblock.locator for xblock in xblocks]
# Verify link navigation # Verify link navigation
for index in range(2): for index in range(2):
self.bookmarks.click_bookmark(index) self.bookmarks_page.click_bookmarked_block(index)
self.courseware_page.wait_for_page() self.courseware_page.wait_for_page()
self.assertTrue(self.courseware_page.active_usage_id() in xblock_usage_ids) self.assertTrue(self.courseware_page.active_usage_id() in xblock_usage_ids)
self.courseware_page.visit().wait_for_page() self.courseware_page.visit().wait_for_page()
self.bookmarks.click_bookmarks_button() self.bookmarks_page.click_bookmarks_button()
def test_unreachable_bookmark(self): def test_unreachable_bookmark(self):
""" """
...@@ -195,13 +258,34 @@ class BookmarksTest(BookmarksTestMixin): ...@@ -195,13 +258,34 @@ class BookmarksTest(BookmarksTestMixin):
When I click on deleted bookmark When I click on deleted bookmark
Then I should navigated to 404 page Then I should navigated to 404 page
""" """
self._bookmarks_blocks(self.course_fixture.get_nested_xblocks(category="problem")) self._test_setup()
self._bookmark_units(2)
self._delete_section(0) self._delete_section(0)
self.bookmarks.click_bookmarks_button() self.bookmarks_page.click_bookmarks_button()
self.assertTrue(self.bookmarks.results_present()) self.assertTrue(self.bookmarks_page.results_present())
self.assertEqual(self.bookmarks.count(), 2) self.assertEqual(self.bookmarks_page.count(), 2)
self.bookmarks.click_bookmark(0) self.bookmarks_page.click_bookmarked_block(1)
self.assertTrue(is_404_page(self.browser)) self.assertTrue(is_404_page(self.browser))
def test_page_size_limit(self):
"""
Scenario: We can get more bookmarks if page size is greater than default page size.
Note:
* Current Bookmarks API page_size value is 10.
* page_size value in bookmarks client side is set to 500.
Given that I am a registered user
And I visit my courseware page
And I have bookmarked all the units available
Then I click on Bookmarks button
And I should see a bookmarked list
And bookmark list contains 11 bookmarked items
"""
self._test_setup(11)
self._bookmark_units(11)
self.bookmarks_page.click_bookmarks_button()
self.assertTrue(self.bookmarks_page.results_present())
self.assertEqual(self.bookmarks_page.count(), 11)
...@@ -29,6 +29,7 @@ class CoursewareTest(UniqueCourseTest): ...@@ -29,6 +29,7 @@ class CoursewareTest(UniqueCourseTest):
super(CoursewareTest, self).setUp() super(CoursewareTest, self).setUp()
self.courseware_page = CoursewarePage(self.browser, self.course_id) self.courseware_page = CoursewarePage(self.browser, self.course_id)
self.course_nav = CourseNavPage(self.browser)
self.course_outline = CourseOutlinePage( self.course_outline = CourseOutlinePage(
self.browser, self.browser,
...@@ -38,12 +39,12 @@ class CoursewareTest(UniqueCourseTest): ...@@ -38,12 +39,12 @@ class CoursewareTest(UniqueCourseTest):
) )
# Install a course with sections/problems, tabs, updates, and handouts # Install a course with sections/problems, tabs, updates, and handouts
course_fix = CourseFixture( self.course_fix = CourseFixture(
self.course_info['org'], self.course_info['number'], self.course_info['org'], self.course_info['number'],
self.course_info['run'], self.course_info['display_name'] self.course_info['run'], self.course_info['display_name']
) )
course_fix.add_children( self.course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section 1').add_children( XBlockFixtureDesc('chapter', 'Test Section 1').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection 1').add_children( XBlockFixtureDesc('sequential', 'Test Subsection 1').add_children(
XBlockFixtureDesc('problem', 'Test Problem 1') XBlockFixtureDesc('problem', 'Test Problem 1')
...@@ -67,6 +68,18 @@ class CoursewareTest(UniqueCourseTest): ...@@ -67,6 +68,18 @@ class CoursewareTest(UniqueCourseTest):
self.problem_page = ProblemPage(self.browser) self.problem_page = ProblemPage(self.browser)
self.assertEqual(self.problem_page.problem_name, 'TEST PROBLEM 1') self.assertEqual(self.problem_page.problem_name, 'TEST PROBLEM 1')
def _change_problem_release_date_in_studio(self):
"""
"""
self.course_outline.q(css=".subsection-header-actions .configure-button").first.click()
self.course_outline.q(css="#start_date").fill("01/01/2030")
self.course_outline.q(css=".action-save").first.click()
def _create_breadcrumb(self, index):
""" Create breadcrumb """
return ['Test Section {}'.format(index), 'Test Subsection {}'.format(index), 'Test Problem {}'.format(index)]
def _auto_auth(self, username, email, staff): def _auto_auth(self, username, email, staff):
""" """
Logout and login with given credentials. Logout and login with given credentials.
...@@ -92,6 +105,9 @@ class CoursewareTest(UniqueCourseTest): ...@@ -92,6 +105,9 @@ class CoursewareTest(UniqueCourseTest):
# Set release date for subsection in future. # Set release date for subsection in future.
self.course_outline.change_problem_release_date_in_studio() self.course_outline.change_problem_release_date_in_studio()
# Wait for 2 seconds to save new date.
time.sleep(2)
# Logout and login as a student. # Logout and login as a student.
LogoutPage(self.browser).visit() LogoutPage(self.browser).visit()
self._auto_auth(self.USERNAME, self.EMAIL, False) self._auto_auth(self.USERNAME, self.EMAIL, False)
...@@ -246,6 +262,23 @@ class ProctoredExamTest(UniqueCourseTest): ...@@ -246,6 +262,23 @@ class ProctoredExamTest(UniqueCourseTest):
self.courseware_page.start_timed_exam() self.courseware_page.start_timed_exam()
self.assertTrue(self.courseware_page.is_timer_bar_present) self.assertTrue(self.courseware_page.is_timer_bar_present)
def test_course_tree_breadcrumb(self):
"""
Scenario: Correct course tree breadcrumb is shown.
Given that I am a registered user
And I visit my courseware page
Then I should see correct course tree breadcrumb
"""
self.courseware_page.visit()
xblocks = self.course_fix.get_nested_xblocks(category="problem")
for index in range(1, len(xblocks) + 1):
self.course_nav.go_to_section('Test Section {}'.format(index), 'Test Subsection {}'.format(index))
courseware_page_breadcrumb = self.courseware_page.breadcrumb
expected_breadcrumb = self._create_breadcrumb(index)
self.assertEqual(courseware_page_breadcrumb, expected_breadcrumb)
def test_time_allotted_field_is_not_visible_with_none_exam(self): def test_time_allotted_field_is_not_visible_with_none_exam(self):
""" """
Given that I am a staff member Given that I am a staff member
......
...@@ -145,8 +145,8 @@ class BookmarksViewTestsMixin(ModuleStoreTestCase): ...@@ -145,8 +145,8 @@ class BookmarksViewTestsMixin(ModuleStoreTestCase):
class BookmarksListViewTests(BookmarksViewTestsMixin): class BookmarksListViewTests(BookmarksViewTestsMixin):
""" """
This contains the tests for GET & POST methods of bookmark.views.BookmarksListView class This contains the tests for GET & POST methods of bookmark.views.BookmarksListView class
GET /api/bookmarks/v0/bookmarks/?course_id={course_id1} GET /api/bookmarks/v1/bookmarks/?course_id={course_id1}
POST /api/bookmarks/v0/bookmarks POST /api/bookmarks/v1/bookmarks
""" """
@ddt.data( @ddt.data(
('course_id={}', False), ('course_id={}', False),
......
...@@ -42,6 +42,7 @@ from courseware.entrance_exams import ( ...@@ -42,6 +42,7 @@ from courseware.entrance_exams import (
) )
from edxmako.shortcuts import render_to_string from edxmako.shortcuts import render_to_string
from eventtracking import tracker from eventtracking import tracker
from lms.djangoapps.bookmarks.services import BookmarksService
from lms.djangoapps.lms_xblock.field_data import LmsFieldData from lms.djangoapps.lms_xblock.field_data import LmsFieldData
from lms.djangoapps.lms_xblock.runtime import LmsModuleSystem, unquote_slashes, quote_slashes from lms.djangoapps.lms_xblock.runtime import LmsModuleSystem, unquote_slashes, quote_slashes
from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig
...@@ -715,6 +716,8 @@ def get_module_system_for_user(user, student_data, # TODO # pylint: disable=to ...@@ -715,6 +716,8 @@ def get_module_system_for_user(user, student_data, # TODO # pylint: disable=to
"reverification": ReverificationService(), "reverification": ReverificationService(),
'proctoring': ProctoringService(), 'proctoring': ProctoringService(),
'credit': CreditService(), 'credit': CreditService(),
'reverification': ReverificationService(),
'bookmarks': BookmarksService(user=user),
}, },
get_user_role=lambda: get_user_role(user, course_id), get_user_role=lambda: get_user_role(user, course_id),
descriptor_runtime=descriptor._runtime, # pylint: disable=protected-access descriptor_runtime=descriptor._runtime, # pylint: disable=protected-access
......
...@@ -73,6 +73,7 @@ TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT ...@@ -73,6 +73,7 @@ TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
@XBlock.needs("i18n") @XBlock.needs("i18n")
@XBlock.needs("fs") @XBlock.needs("fs")
@XBlock.needs("user") @XBlock.needs("user")
@XBlock.needs("bookmarks")
class PureXBlock(XBlock): class PureXBlock(XBlock):
""" """
Pure XBlock to use in tests. Pure XBlock to use in tests.
...@@ -1232,6 +1233,7 @@ class ViewInStudioTest(ModuleStoreTestCase): ...@@ -1232,6 +1233,7 @@ class ViewInStudioTest(ModuleStoreTestCase):
self.request.user = self.staff_user self.request.user = self.staff_user
self.request.session = {} self.request.session = {}
self.module = None self.module = None
self.default_context = {'bookmarked': False, 'username': self.user.username}
def _get_module(self, course_id, descriptor, location): def _get_module(self, course_id, descriptor, location):
""" """
...@@ -1290,14 +1292,14 @@ class MongoViewInStudioTest(ViewInStudioTest): ...@@ -1290,14 +1292,14 @@ class MongoViewInStudioTest(ViewInStudioTest):
def test_view_in_studio_link_studio_course(self): def test_view_in_studio_link_studio_course(self):
"""Regular Studio courses should see 'View in Studio' links.""" """Regular Studio courses should see 'View in Studio' links."""
self.setup_mongo_course() self.setup_mongo_course()
result_fragment = self.module.render(STUDENT_VIEW) result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
self.assertIn('View Unit in Studio', result_fragment.content) self.assertIn('View Unit in Studio', result_fragment.content)
def test_view_in_studio_link_only_in_top_level_vertical(self): def test_view_in_studio_link_only_in_top_level_vertical(self):
"""Regular Studio courses should not see 'View in Studio' for child verticals of verticals.""" """Regular Studio courses should not see 'View in Studio' for child verticals of verticals."""
self.setup_mongo_course() self.setup_mongo_course()
# Render the parent vertical, then check that there is only a single "View Unit in Studio" link. # Render the parent vertical, then check that there is only a single "View Unit in Studio" link.
result_fragment = self.module.render(STUDENT_VIEW) result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
# The single "View Unit in Studio" link should appear before the first xmodule vertical definition. # The single "View Unit in Studio" link should appear before the first xmodule vertical definition.
parts = result_fragment.content.split('data-block-type="vertical"') parts = result_fragment.content.split('data-block-type="vertical"')
self.assertEqual(3, len(parts), "Did not find two vertical blocks") self.assertEqual(3, len(parts), "Did not find two vertical blocks")
...@@ -1308,7 +1310,7 @@ class MongoViewInStudioTest(ViewInStudioTest): ...@@ -1308,7 +1310,7 @@ class MongoViewInStudioTest(ViewInStudioTest):
def test_view_in_studio_link_xml_authored(self): def test_view_in_studio_link_xml_authored(self):
"""Courses that change 'course_edit_method' setting can hide 'View in Studio' links.""" """Courses that change 'course_edit_method' setting can hide 'View in Studio' links."""
self.setup_mongo_course(course_edit_method='XML') self.setup_mongo_course(course_edit_method='XML')
result_fragment = self.module.render(STUDENT_VIEW) result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
self.assertNotIn('View Unit in Studio', result_fragment.content) self.assertNotIn('View Unit in Studio', result_fragment.content)
...@@ -1321,19 +1323,19 @@ class MixedViewInStudioTest(ViewInStudioTest): ...@@ -1321,19 +1323,19 @@ class MixedViewInStudioTest(ViewInStudioTest):
def test_view_in_studio_link_mongo_backed(self): def test_view_in_studio_link_mongo_backed(self):
"""Mixed mongo courses that are mongo backed should see 'View in Studio' links.""" """Mixed mongo courses that are mongo backed should see 'View in Studio' links."""
self.setup_mongo_course() self.setup_mongo_course()
result_fragment = self.module.render(STUDENT_VIEW) result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
self.assertIn('View Unit in Studio', result_fragment.content) self.assertIn('View Unit in Studio', result_fragment.content)
def test_view_in_studio_link_xml_authored(self): def test_view_in_studio_link_xml_authored(self):
"""Courses that change 'course_edit_method' setting can hide 'View in Studio' links.""" """Courses that change 'course_edit_method' setting can hide 'View in Studio' links."""
self.setup_mongo_course(course_edit_method='XML') self.setup_mongo_course(course_edit_method='XML')
result_fragment = self.module.render(STUDENT_VIEW) result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
self.assertNotIn('View Unit in Studio', result_fragment.content) self.assertNotIn('View Unit in Studio', result_fragment.content)
def test_view_in_studio_link_xml_backed(self): def test_view_in_studio_link_xml_backed(self):
"""Course in XML only modulestore should not see 'View in Studio' links.""" """Course in XML only modulestore should not see 'View in Studio' links."""
self.setup_xml_course() self.setup_xml_course()
result_fragment = self.module.render(STUDENT_VIEW) result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
self.assertNotIn('View Unit in Studio', result_fragment.content) self.assertNotIn('View Unit in Studio', result_fragment.content)
...@@ -1826,7 +1828,7 @@ class LMSXBlockServiceBindingTest(ModuleStoreTestCase): ...@@ -1826,7 +1828,7 @@ class LMSXBlockServiceBindingTest(ModuleStoreTestCase):
self.request_token = Mock() self.request_token = Mock()
@XBlock.register_temp_plugin(PureXBlock, identifier='pure') @XBlock.register_temp_plugin(PureXBlock, identifier='pure')
@ddt.data("user", "i18n", "fs", "field-data") @ddt.data("user", "i18n", "fs", "field-data", "bookmarks")
def test_expected_services_exist(self, expected_service): def test_expected_services_exist(self, expected_service):
""" """
Tests that the 'user', 'i18n', and 'fs' services are provided by the LMS runtime. Tests that the 'user', 'i18n', and 'fs' services are provided by the LMS runtime.
......
...@@ -119,7 +119,7 @@ class SplitTestBase(ModuleStoreTestCase): ...@@ -119,7 +119,7 @@ class SplitTestBase(ModuleStoreTestCase):
content = resp.content content = resp.content
# Assert we see the proper icon in the top display # Assert we see the proper icon in the top display
self.assertIn('<a class="{} inactive progress-0"'.format(self.ICON_CLASSES[user_tag]), content) self.assertIn('<a class="{} inactive progress-0 nav-item"'.format(self.ICON_CLASSES[user_tag]), content)
# And proper tooltips # And proper tooltips
for tooltip in self.TOOLTIPS[user_tag]: for tooltip in self.TOOLTIPS[user_tag]:
self.assertIn(tooltip, content) self.assertIn(tooltip, content)
......
...@@ -403,6 +403,8 @@ def _index_bulk_op(request, course_key, chapter, section, position): ...@@ -403,6 +403,8 @@ def _index_bulk_op(request, course_key, chapter, section, position):
if survey.utils.must_answer_survey(course, user): if survey.utils.must_answer_survey(course, user):
return redirect(reverse('course_survey', args=[unicode(course.id)])) return redirect(reverse('course_survey', args=[unicode(course.id)]))
bookmarks_api_url = reverse('bookmarks')
try: try:
field_data_cache = FieldDataCache.cache_for_descriptor_descendents( field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course_key, user, course, depth=2) course_key, user, course, depth=2)
...@@ -428,7 +430,7 @@ def _index_bulk_op(request, course_key, chapter, section, position): ...@@ -428,7 +430,7 @@ def _index_bulk_op(request, course_key, chapter, section, position):
'studio_url': studio_url, 'studio_url': studio_url,
'masquerade': masquerade, 'masquerade': masquerade,
'xqa_server': settings.FEATURES.get('XQA_SERVER', "http://your_xqa_server.com"), 'xqa_server': settings.FEATURES.get('XQA_SERVER', "http://your_xqa_server.com"),
'reverifications': fetch_reverify_banner_info(request, course_key), 'bookmarks_api_url': bookmarks_api_url,
'language_preference': language_preference, 'language_preference': language_preference,
} }
......
...@@ -5,8 +5,11 @@ ...@@ -5,8 +5,11 @@
'use strict'; 'use strict';
return Backbone.Collection.extend({ return Backbone.Collection.extend({
model : BookmarkModel, model: BookmarkModel,
url: '/api/bookmarks/v0/bookmarks/',
url: function() {
return $(".courseware-bookmarks-button").data('bookmarksApiUrl');
},
parse: function(response) { parse: function(response) {
return response.results; return response.results;
......
RequireJS.require([ RequireJS.require([
'js/bookmarks/views/bookmarks_button' 'js/bookmarks/views/bookmarks_list_button'
], function (BookmarksButton) { ], function (BookmarksListButton) {
'use strict'; 'use strict';
return new BookmarksButton(); return new BookmarksListButton();
}); });
;(function (define, undefined) {
'use strict';
define(['gettext', 'jquery', 'underscore', 'backbone', 'js/views/message'],
function (gettext, $, _, Backbone, MessageView) {
return Backbone.View.extend({
errorIcon: '<i class="fa fa-fw fa-exclamation-triangle message-error" aria-hidden="true"></i>',
errorMessage: gettext('An error has occurred. Please try again.'),
srAddBookmarkText: gettext('Click to add'),
srRemoveBookmarkText: gettext('Click to remove'),
events: {
'click': 'toggleBookmark'
},
initialize: function (options) {
this.apiUrl = options.apiUrl;
this.bookmarkId = options.bookmarkId;
this.bookmarked = options.bookmarked;
this.usageId = options.usageId;
this.setBookmarkState(this.bookmarked);
},
toggleBookmark: function(event) {
event.preventDefault();
if (this.$el.hasClass('bookmarked')) {
this.removeBookmark();
} else {
this.addBookmark();
}
},
addBookmark: function() {
var view = this;
$.ajax({
data: {usage_id: view.usageId},
type: "POST",
url: view.apiUrl,
dataType: 'json',
success: function () {
view.$el.trigger('bookmark:add');
view.setBookmarkState(true);
},
error: function() {
view.showError();
}
});
},
removeBookmark: function() {
var view = this;
var deleteUrl = view.apiUrl + view.bookmarkId + '/';
$.ajax({
type: "DELETE",
url: deleteUrl,
success: function () {
view.$el.trigger('bookmark:remove');
view.setBookmarkState(false);
},
error: function() {
view.showError();
}
});
},
setBookmarkState: function(bookmarked) {
if (bookmarked) {
this.$el.addClass('bookmarked');
this.$el.attr('aria-pressed', 'true');
this.$el.find('.bookmark-sr').text(this.srRemoveBookmarkText);
} else {
this.$el.removeClass('bookmarked');
this.$el.attr('aria-pressed', 'false');
this.$el.find('.bookmark-sr').text(this.srAddBookmarkText);
}
},
showError: function() {
if (!this.messageView) {
this.messageView = new MessageView({
el: $('.coursewide-message-banner'),
templateId: '#message_banner-tpl'
});
}
this.messageView.showMessage(this.errorMessage, this.errorIcon);
}
});
});
}).call(this, define || RequireJS.define);
...@@ -16,6 +16,8 @@ ...@@ -16,6 +16,8 @@
errorMessage: gettext('An error has occurred. Please try again.'), errorMessage: gettext('An error has occurred. Please try again.'),
loadingMessage: gettext('Loading'), loadingMessage: gettext('Loading'),
PAGE_SIZE: 500,
events : { events : {
'click .bookmarks-results-list-item': 'visitBookmark' 'click .bookmarks-results-list-item': 'visitBookmark'
}, },
...@@ -48,7 +50,7 @@ ...@@ -48,7 +50,7 @@
this.collection.fetch({ this.collection.fetch({
reset: true, reset: true,
data: {course_id: this.courseId, fields: 'display_name,path'} data: {course_id: this.courseId, page_size: this.PAGE_SIZE, fields: 'display_name,path'}
}).done(function () { }).done(function () {
view.hideLoadingMessage(); view.hideLoadingMessage();
view.render(); view.render();
......
...@@ -16,8 +16,6 @@ ...@@ -16,8 +16,6 @@
}, },
initialize: function () { initialize: function () {
this.template = _.template($('#bookmarks_button-tpl').text());
this.bookmarksListView = new BookmarksListView({ this.bookmarksListView = new BookmarksListView({
collection: new BookmarksCollection(), collection: new BookmarksCollection(),
loadingMessageView: new MessageView({el: $(this.loadingMessageElement)}), loadingMessageView: new MessageView({el: $(this.loadingMessageElement)}),
......
<div class="coursewide-message-banner" aria-live="polite"></div>
<div class="xblock xblock-student_view xblock-student_view-vertical xblock-initialized">
<div class="bookmark-button-wrapper">
<button class="btn bookmark-button"
aria-pressed="false"
data-bookmark-id="bilbo,usage_1">
<span class="sr bookmark-sr"></span>&nbsp;
Bookmark
</button>
</div>
</div>
<div class="courseware-bookmarks-button"> <div class="courseware-bookmarks-button" data-bookmarks-api-url="/api/bookmarks/v1/bookmarks/">
<button type="button" class="bookmarks-list-button is-inactive" aria-pressed="false"> <button type="button" class="bookmarks-list-button is-inactive" aria-pressed="false">
Bookmarks Bookmarks
</button> </button>
......
define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/bookmarks/views/bookmark_button'
],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, BookmarkButtonView) {
'use strict';
describe("bookmarks.button", function () {
var API_URL = 'bookmarks/api/v1/bookmarks/';
beforeEach(function () {
loadFixtures('js/fixtures/bookmarks/bookmark_button.html');
TemplateHelpers.installTemplates(
[
'templates/fields/message_banner'
]
);
});
var createBookmarkButtonView = function(isBookmarked) {
return new BookmarkButtonView({
el: '.bookmark-button',
bookmarked: isBookmarked,
bookmarkId: 'bilbo,usage_1',
usageId: 'usage_1',
apiUrl: API_URL
});
};
var verifyBookmarkButtonState = function (view, bookmarked) {
if (bookmarked) {
expect(view.$el).toHaveAttr('aria-pressed', 'true');
expect(view.$el).toHaveClass('bookmarked');
expect(view.$el.find('.bookmark-sr').text()).toBe('Click to remove');
} else {
expect(view.$el).toHaveAttr('aria-pressed', 'false');
expect(view.$el).not.toHaveClass('bookmarked');
expect(view.$el.find('.bookmark-sr').text()).toBe('Click to add');
}
expect(view.$el.data('bookmarkId')).toBe('bilbo,usage_1');
};
it("rendered correctly ", function () {
var view = createBookmarkButtonView(false);
verifyBookmarkButtonState(view, false);
// with bookmarked true
view = createBookmarkButtonView(true);
verifyBookmarkButtonState(view, true);
});
it("bookmark/un-bookmark the block correctly", function () {
var addBookmarkedData = {bookmarked: true, handler: 'removeBookmark', event: 'bookmark:remove', method: 'DELETE', url: API_URL + 'bilbo,usage_1/', body: null};
var removeBookmarkData = {bookmarked: false, handler: 'addBookmark', event: 'bookmark:add', method: 'POST', url: API_URL, body: 'usage_id=usage_1'};
var requests = AjaxHelpers.requests(this);
_.each([[addBookmarkedData, removeBookmarkData], [removeBookmarkData, addBookmarkedData]], function(actionsData) {
var firstActionData = actionsData[0];
var secondActionData = actionsData[1];
var bookmarkButtonView = createBookmarkButtonView(firstActionData.bookmarked);
verifyBookmarkButtonState(bookmarkButtonView, firstActionData.bookmarked);
spyOn(bookmarkButtonView, firstActionData.handler).andCallThrough();
spyOnEvent(bookmarkButtonView.$el, firstActionData.event);
bookmarkButtonView.$el.click();
AjaxHelpers.expectRequest(requests, firstActionData.method, firstActionData.url, firstActionData.body);
expect(bookmarkButtonView[firstActionData.handler]).toHaveBeenCalled();
AjaxHelpers.respondWithJson(requests, {});
expect(firstActionData.event).toHaveBeenTriggeredOn(bookmarkButtonView.$el);
bookmarkButtonView[firstActionData.handler].reset();
verifyBookmarkButtonState(bookmarkButtonView, secondActionData.bookmarked);
spyOn(bookmarkButtonView, secondActionData.handler).andCallThrough();
spyOnEvent(bookmarkButtonView.$el, secondActionData.event);
bookmarkButtonView.$el.click();
AjaxHelpers.expectRequest(requests, secondActionData.method, secondActionData.url, secondActionData.body);
expect(bookmarkButtonView[secondActionData.handler]).toHaveBeenCalled();
AjaxHelpers.respondWithJson(requests, {});
expect(secondActionData.event).toHaveBeenTriggeredOn(bookmarkButtonView.$el);
verifyBookmarkButtonState(bookmarkButtonView, firstActionData.bookmarked);
});
});
it("shows an error message for HTTP 500", function () {
var requests = AjaxHelpers.requests(this),
$messageBanner = $('.coursewide-message-banner'),
bookmarkButtonView = createBookmarkButtonView(false);
bookmarkButtonView.$el.click();
AjaxHelpers.respondWithError(requests);
expect($messageBanner.text().trim()).toBe(bookmarkButtonView.errorMessage);
// For bookmarked button.
bookmarkButtonView = createBookmarkButtonView(true);
bookmarkButtonView.$el.click();
AjaxHelpers.respondWithError(requests);
expect($messageBanner.text().trim()).toBe(bookmarkButtonView.errorMessage);
});
});
});
define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers', define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/bookmarks/views/bookmarks_button' 'js/bookmarks/views/bookmarks_list_button'
], ],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, BookmarksButtonView) { function (Backbone, $, _, AjaxHelpers, TemplateHelpers, BookmarksListButtonView) {
'use strict'; 'use strict';
describe("lms.courseware.bookmarks", function () { describe("lms.courseware.bookmarks", function () {
var bookmarksButtonView; var bookmarksButtonView;
var BOOKMARKS_API_URL = '/api/bookmarks/v0/bookmarks/'; var BOOKMARKS_API_URL = '/api/bookmarks/v1/bookmarks/';
beforeEach(function () { beforeEach(function () {
loadFixtures('js/fixtures/bookmarks/bookmarks.html'); loadFixtures('js/fixtures/bookmarks/bookmarks.html');
...@@ -18,7 +18,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j ...@@ -18,7 +18,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j
] ]
); );
bookmarksButtonView = new BookmarksButtonView(); bookmarksButtonView = new BookmarksListButtonView();
this.addMatchers({ this.addMatchers({
toHaveBeenCalledWithUrl: function (expectedUrl) { toHaveBeenCalledWithUrl: function (expectedUrl) {
...@@ -124,7 +124,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j ...@@ -124,7 +124,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j
it("has rendered bookmarked list correctly", function () { it("has rendered bookmarked list correctly", function () {
var requests = AjaxHelpers.requests(this); var requests = AjaxHelpers.requests(this);
var url = BOOKMARKS_API_URL + '?course_id=COURSE_ID&fields=display_name%2Cpath'; var url = BOOKMARKS_API_URL + '?course_id=COURSE_ID&page_size=500&fields=display_name%2Cpath';
var expectedData = createBookmarksData(3); var expectedData = createBookmarksData(3);
spyOn(bookmarksButtonView.bookmarksListView, 'courseId').andReturn('COURSE_ID'); spyOn(bookmarksButtonView.bookmarksListView, 'courseId').andReturn('COURSE_ID');
......
...@@ -87,6 +87,13 @@ ...@@ -87,6 +87,13 @@
// Discussion classes loaded explicitly until they are converted to use RequireJS // Discussion classes loaded explicitly until they are converted to use RequireJS
'DiscussionModuleView': 'xmodule_js/common_static/coffee/src/discussion/discussion_module_view', 'DiscussionModuleView': 'xmodule_js/common_static/coffee/src/discussion/discussion_module_view',
'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': 'js/views/message',
// edxnotes // edxnotes
'annotator_1.2.9': 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min', 'annotator_1.2.9': 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min',
...@@ -738,7 +745,8 @@ ...@@ -738,7 +745,8 @@
'lms/include/teams/js/spec/views/team_join_spec.js' 'lms/include/teams/js/spec/views/team_join_spec.js'
'lms/include/js/spec/discovery/discovery_spec.js', 'lms/include/js/spec/discovery/discovery_spec.js',
'lms/include/js/spec/ccx/schedule_spec.js', 'lms/include/js/spec/ccx/schedule_spec.js',
'lms/include/js/spec/bookmarks/bookmarks_spec.js' 'lms/include/js/spec/bookmarks/bookmarks_list_view_spec.js',
'lms/include/js/spec/bookmarks/bookmark_button_view_spec.js'
]); ]);
}).call(this, requirejs, define); }).call(this, requirejs, define);
...@@ -403,6 +403,10 @@ div.course-wrapper { ...@@ -403,6 +403,10 @@ div.course-wrapper {
} }
} }
.sequence .path {
margin-bottom: ($baseline/2);
}
div#seq_content { div#seq_content {
h1 { h1 {
background: none; background: none;
......
...@@ -67,8 +67,8 @@ ...@@ -67,8 +67,8 @@
margin-bottom: $baseline; margin-bottom: $baseline;
&:hover { &:hover {
border-color: $link-color; border-color: $m-blue;
color: $link-color; color: $m-blue;
} }
} }
...@@ -87,6 +87,7 @@ ...@@ -87,6 +87,7 @@
position: relative; position: relative;
top: -7px; top: -7px;
font-family: FontAwesome; font-family: FontAwesome;
color: $m-blue;
} }
.list-item-content { .list-item-content {
...@@ -124,4 +125,59 @@ ...@@ -124,4 +125,59 @@
.bookmarks-empty-detail { .bookmarks-empty-detail {
@extend %t-copy-sub1; @extend %t-copy-sub1;
} }
\ No newline at end of file
// Rules for bookmark icon shown on each sequence nav item
i.bookmarked {
top: -3px;
position: absolute;
left: ($baseline/4);
}
// Rules for bookmark button's different styles
.bookmark-button-wrapper {
text-align: right;
margin-bottom: 10px;
}
@mixin base-style($border-color, $content-color) {
background: none;
border: 1px solid $border-color;
border-radius: ($baseline/4);
color: $content-color;
&:focus, &:active {
box-shadow: none;
}
}
@mixin icon-style($content, $color) {
&:before {
content: $content;
font-family: FontAwesome;
color: $color;
}
}
@mixin hover-style($border-color, $content-color, $icon-content) {
&:hover {
background: none;
border: 1px solid $border-color;
color: $content-color;
@include icon-style($icon-content, $content-color);
}
}
.bookmark-button.bookmarked {
@include base-style($m-blue, $m-blue);
@include icon-style("\f02e", $m-blue);
@include hover-style($light-gray, $black, "\f097");
}
.bookmark-button:not(.bookmarked) {
@include base-style($light-gray, $black);
@include icon-style("\f097", $black);
@include hover-style($m-blue, $m-blue, "\f02e");
}
<%! from django.utils.translation import ugettext as _ %>
<%page args="bookmark_id, is_bookmarked" />
<div class="bookmark-button-wrapper">
<button class="btn bookmark-button ${"bookmarked" if is_bookmarked else ""}"
aria-pressed="${"true" if is_bookmarked else "false"}"
data-bookmark-id="${bookmark_id}">
<span class="sr bookmark-sr">${_("Click to remove") if is_bookmarked else _("Click to add")}</span>&nbsp;
${_("Bookmark")}
</button>
</div>
...@@ -24,6 +24,13 @@ ${page_title_breadcrumbs(course_name())} ...@@ -24,6 +24,13 @@ ${page_title_breadcrumbs(course_name())}
</title></%block> </title></%block>
<%block name="header_extras"> <%block name="header_extras">
% for template_name in ["message_banner"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="fields/${template_name}.underscore" />
</script>
% endfor
% for template_name in ["image-modal"]: % for template_name in ["image-modal"]:
<script type="text/template" id="${template_name}-tpl"> <script type="text/template" id="${template_name}-tpl">
<%static:include path="common/templates/${template_name}.underscore" /> <%static:include path="common/templates/${template_name}.underscore" />
...@@ -124,6 +131,8 @@ ${fragment.foot_html()} ...@@ -124,6 +131,8 @@ ${fragment.foot_html()}
</%block> </%block>
<div class="coursewide-message-banner" aria-live="polite"></div>
% if default_tab: % if default_tab:
<%include file="/courseware/course_navigation.html" /> <%include file="/courseware/course_navigation.html" />
% else: % else:
...@@ -149,7 +158,7 @@ ${fragment.foot_html()} ...@@ -149,7 +158,7 @@ ${fragment.foot_html()}
<div class="wrapper-course-modes"> <div class="wrapper-course-modes">
<div class="courseware-bookmarks-button"> <div class="courseware-bookmarks-button" data-bookmarks-api-url="${bookmarks_api_url}">
<button type="button" class="bookmarks-list-button is-inactive" aria-pressed="false"> <button type="button" class="bookmarks-list-button is-inactive" aria-pressed="false">
${_('Bookmarks')} ${_('Bookmarks')}
</button> </button>
......
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<div id="sequence_${element_id}" class="sequence" data-id="${item_id}" data-position="${position}" data-ajax-url="${ajax_url}" > <div id="sequence_${element_id}" class="sequence" data-id="${item_id}" data-position="${position}" data-ajax-url="${ajax_url}" >
<div class="path"></div>
<div class="sequence-nav"> <div class="sequence-nav">
<button class="sequence-nav-button button-previous"> <button class="sequence-nav-button button-previous">
<span class="icon fa fa-chevron-prev" aria-hidden="true"></span><span class="sr">${_('Previous')}</span> <span class="icon fa fa-chevron-prev" aria-hidden="true"></span><span class="sr">${_('Previous')}</span>
...@@ -13,16 +14,18 @@ ...@@ -13,16 +14,18 @@
## implementation note: will need to figure out how to handle combining detail ## implementation note: will need to figure out how to handle combining detail
## statuses of multiple modules in js. ## statuses of multiple modules in js.
<li> <li>
<a class="seq_${item['type']} inactive progress-${item['progress_status']}" <a class="seq_${item['type']} inactive progress-${item['progress_status']} nav-item"
data-id="${item['id']}" data-id="${item['id']}"
data-element="${idx+1}" data-element="${idx+1}"
href="javascript:void(0);" href="javascript:void(0);"
data-page-title="${item['page_title']|h}" data-page-title="${item['page_title']|h}"
data-path="${item['path']}"
aria-controls="seq_contents_${idx}" aria-controls="seq_contents_${idx}"
id="tab_${idx}" id="tab_${idx}"
tabindex="0"> tabindex="0">
<i class="icon fa seq_${item['type']}" aria-hidden="true"></i> <i class="icon fa seq_${item['type']}" aria-hidden="true"></i>
<p><span class="sr">${item['type']}</span>${item['title']}</p> <i class="fa fa-fw fa-bookmark bookmark-icon ${"is-hidden" if not item['bookmarked'] else "bookmarked"}" aria-hidden="true"></i>
<p><span class="sr">${item['type']}</span> ${item['title']}<span class="sr bookmark-icon-sr">&nbsp;${_("Bookmarked") if item['bookmarked'] else ""}</span></p>
</a> </a>
</li> </li>
% endfor % endfor
......
% if show_bookmark_button:
<%include file='bookmark_button.html' args="bookmark_id=bookmark_id, is_bookmarked=bookmarked"/>
% endif
<div class="vert-mod"> <div class="vert-mod">
% for idx, item in enumerate(items): % for idx, item in enumerate(items):
<div class="vert vert-${idx}" data-id="${item['id']}"> <div class="vert vert-${idx}" data-id="${item['id']}">
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment