Commit c9b87aa0 by muhammad-ammar Committed by muzaffaryousaf

Unit Bookmarks List View

TNL-1958
parent cfd1fabb
This source diff could not be displayed because it is too large. You can view the blob instead.
"""
Courseware Boomarks
"""
from bok_choy.promise import EmptyPromise
from .course_page import CoursePage
class BookmarksPage(CoursePage):
"""
Coursware Bookmarks Page.
"""
url = None
url_path = "courseware/"
BOOKMARKS_BUTTON_SELECTOR = '.bookmarks-list-button'
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
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):
""" Click on Bookmarks button """
self.q(css=self.BOOKMARKS_BUTTON_SELECTOR).first.click()
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
def results_header_text(self):
""" Returns the bookmarks results header text """
return self.q(css='.bookmarks-results-header').text[0]
def empty_header_text(self):
""" Returns the bookmarks empty header text """
return self.q(css='.bookmarks-empty-header').text[0]
def empty_list_text(self):
""" Returns the bookmarks empty list text """
return self.q(css='.bookmarks-empty-detail-title').text[0]
def count(self):
""" Returns the total number of bookmarks in the list """
return len(self.q(css=self.BOOKMARKED_ITEMS_SELECTOR).results)
def breadcrumbs(self):
""" Return list of breadcrumbs for all bookmarks """
breadcrumbs = self.q(css=self.BOOKMARKED_BREADCRUMBS).text
return [breadcrumb.replace('\n', '').split('-') for breadcrumb in breadcrumbs]
def click_bookmark(self, index):
"""
Click on bookmark at index `index`
Arguments:
index (int): bookmark index in the list
"""
self.q(css=self.BOOKMARKED_ITEMS_SELECTOR).nth(index).click()
......@@ -171,6 +171,12 @@ class CoursewarePage(CoursePage):
"""
return self.q(css=".proctored_exam_status .exam-timer").is_present()
def active_usage_id(self):
""" Returns the usage id of active sequence item """
get_active = lambda el: 'active' in el.get_attribute('class')
attribute_value = lambda el: el.get_attribute('data-id')
return self.q(css='#sequence-list a').filter(get_active).map(attribute_value).results[0]
class CoursewareSequentialTabPage(CoursePage):
"""
......
......@@ -16,7 +16,7 @@ class CoursewareSearchPage(CoursePage):
@property
def search_results(self):
""" search results list showing """
return self.q(css='#courseware-search-results')
return self.q(css='.courseware-results')
def is_browser_on_page(self):
""" did we find the search bar in the UI """
......
......@@ -344,6 +344,11 @@ def get_element_padding(page, selector):
return page.browser.execute_script(js_script)
def is_404_page(browser):
""" Check if page is 404 """
return 'Page not found (404)' in browser.find_element_by_tag_name('h1').text
class EventsTestMixin(TestCase):
"""
Helpers and setup for running tests that evaluate events emitted
......
# -*- coding: utf-8 -*-
"""
End-to-end tests for the courseware unit bookmarks.
"""
import json
import requests
from ...pages.studio.auto_auth import AutoAuthPage
from ...pages.lms.bookmarks import BookmarksPage
from ...pages.lms.courseware import CoursewarePage
from ...pages.studio.overview import CourseOutlinePage
from ...pages.common.logout import LogoutPage
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
from ...fixtures import LMS_BASE_URL
from ..helpers import EventsTestMixin, UniqueCourseTest, is_404_page
class BookmarksTestMixin(EventsTestMixin, UniqueCourseTest):
"""
Mixin with helper methods for testing Bookmarks.
"""
USERNAME = "STUDENT"
EMAIL = "student@example.com"
COURSE_TREE_INFO = [
['TestSection1', 'TestSubsection1', 'TestProblem1'],
['TestSection2', 'TestSubsection2', 'TestProblem2']
]
def create_course_fixture(self):
""" Create course fixture """
self.course_fixture = CourseFixture( # pylint: disable=attribute-defined-outside-init
self.course_info['org'], self.course_info['number'],
self.course_info['run'], self.course_info['display_name']
)
self.course_fixture.add_children(
XBlockFixtureDesc('chapter', self.COURSE_TREE_INFO[0][0]).add_children(
XBlockFixtureDesc('sequential', self.COURSE_TREE_INFO[0][1]).add_children(
XBlockFixtureDesc('problem', self.COURSE_TREE_INFO[0][2])
)
),
XBlockFixtureDesc('chapter', self.COURSE_TREE_INFO[1][0]).add_children(
XBlockFixtureDesc('sequential', self.COURSE_TREE_INFO[1][1]).add_children(
XBlockFixtureDesc('problem', self.COURSE_TREE_INFO[1][2])
)
)
).install()
class BookmarksTest(BookmarksTestMixin):
"""
Tests to verify bookmarks functionality.
"""
def setUp(self):
"""
Initialize test setup.
"""
super(BookmarksTest, self).setUp()
self.course_outline_page = CourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
self.create_course_fixture()
# Auto-auth register for the course.
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.bookmarks = BookmarksPage(self.browser, self.course_id)
# Use auto-auth to retrieve the session for a logged in user
self.session = requests.Session()
response = self.session.get(LMS_BASE_URL + "/auto_auth?username=STUDENT&email=student@example.com")
self.assertTrue(response.ok, "Failed to get session info")
def _bookmark_unit(self, course_id, usage_id):
""" Bookmark a single unit """
csrftoken = self.session.cookies['csrftoken']
headers = {'Content-type': 'application/json', "X-CSRFToken": csrftoken}
url = LMS_BASE_URL + "/api/bookmarks/v0/bookmarks/?course_id=" + course_id + '&fields=path'
data = json.dumps({'usage_id': usage_id})
response = self.session.post(url, data=data, headers=headers, cookies=self.session.cookies)
response = json.loads(response.text)
self.assertTrue(response['usage_id'] == usage_id, "Failed to bookmark unit")
def _bookmarks_blocks(self, xblocks):
""" Bookmark all units in a course """
for xblock in xblocks:
self._bookmark_unit(self.course_id, usage_id=xblock.locator)
def _delete_section(self, index):
""" Delete a section at index `index` """
# Logout and login as staff
LogoutPage(self.browser).visit()
AutoAuthPage(
self.browser, username=self.USERNAME, email=self.EMAIL, course_id=self.course_id, staff=True
).visit()
# Visit course outline page in studio.
self.course_outline_page.visit()
self.course_outline_page.wait_for_page()
self.course_outline_page.section_at(index).delete()
# Logout and login as a student.
LogoutPage(self.browser).visit()
AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL, course_id=self.course_id).visit()
# Visit courseware as a student.
self.courseware_page.visit()
self.courseware_page.wait_for_page()
def test_empty_bookmarks_list(self):
"""
Scenario: An empty bookmarks list is shown if there are no bookmarked units.
Given that I am a registered user
And I visit my courseware page
And I can see the Bookmarks button
When I click on Bookmarks button
Then I should see an empty bookmarks list
And empty bookmarks list content is correct
"""
self.assertTrue(self.bookmarks.bookmarks_button_visible())
self.bookmarks.click_bookmarks_button()
self.assertEqual(self.bookmarks.results_header_text(), 'MY BOOKMARKS')
self.assertEqual(self.bookmarks.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, "
"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.")
self.assertEqual(self.bookmarks.empty_list_text(), empty_list_text)
def test_bookmarks_list(self):
"""
Scenario: A bookmarks list is shown if there are bookmarked units.
Given that I am a registered user
And I visit my courseware page
And I have bookmarked 2 units
When I click on Bookmarks button
Then I should see a bookmarked list with 2 bookmark links
And breadcrumb trail is correct for a bookmark
When I click on bookmarked link
Then I can navigate to correct bookmarked unit
"""
# NOTE: We are checking the order of bookmarked units at API
# We are unable to check the order here because we are bookmarking
# the units by sending POSTs to API, And the time(created) between
# the bookmarked units is in milliseconds. These milliseconds are
# 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
# incorrect.
xblocks = self.course_fixture.get_nested_xblocks(category="problem")
self._bookmarks_blocks(xblocks)
self.bookmarks.click_bookmarks_button()
self.assertTrue(self.bookmarks.results_present())
self.assertEqual(self.bookmarks.results_header_text(), 'MY BOOKMARKS')
self.assertEqual(self.bookmarks.count(), 2)
bookmarked_breadcrumbs = self.bookmarks.breadcrumbs()
# Verify bookmarked breadcrumbs
self.assertItemsEqual(bookmarked_breadcrumbs, self.COURSE_TREE_INFO)
xblock_usage_ids = [xblock.locator for xblock in xblocks]
# Verify link navigation
for index in range(2):
self.bookmarks.click_bookmark(index)
self.courseware_page.wait_for_page()
self.assertTrue(self.courseware_page.active_usage_id() in xblock_usage_ids)
self.courseware_page.visit().wait_for_page()
self.bookmarks.click_bookmarks_button()
def test_unreachable_bookmark(self):
"""
Scenario: We should get a HTTP 404 for an unreachable bookmark.
Given that I am a registered user
And I visit my courseware page
And I have bookmarked 2 units
Then I delete a bookmarked unit
Then I click on Bookmarks button
And I should see a bookmarked list
When I click on deleted bookmark
Then I should navigated to 404 page
"""
self._bookmarks_blocks(self.course_fixture.get_nested_xblocks(category="problem"))
self._delete_section(0)
self.bookmarks.click_bookmarks_button()
self.assertTrue(self.bookmarks.results_present())
self.assertEqual(self.bookmarks.count(), 2)
self.bookmarks.click_bookmark(0)
self.assertTrue(is_404_page(self.browser))
......@@ -97,6 +97,10 @@ from eventtracking import tracker
import analytics
from courseware.url_helpers import get_redirect_url
from lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
log = logging.getLogger("edx.courseware")
template_imports = {'urllib': urllib}
......@@ -424,6 +428,8 @@ def _index_bulk_op(request, course_key, chapter, section, position):
'studio_url': studio_url,
'masquerade': masquerade,
'xqa_server': settings.FEATURES.get('XQA_SERVER', "http://your_xqa_server.com"),
'reverifications': fetch_reverify_banner_info(request, course_key),
'language_preference': language_preference,
}
now = datetime.now(UTC())
......
......@@ -1176,6 +1176,7 @@ courseware_js = (
for pth in ['courseware', 'histogram', 'navigation']
] +
['js/' + pth + '.js' for pth in ['ajax-error']] +
['js/bookmarks/main.js'] +
sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/modules/**/*.js'))
)
......
;(function (define) {
define(['backbone', 'js/bookmarks/models/bookmark'],
function (Backbone, BookmarkModel) {
'use strict';
return Backbone.Collection.extend({
model : BookmarkModel,
url: '/api/bookmarks/v0/bookmarks/',
parse: function(response) {
return response.results;
}
});
});
})(define || RequireJS.define);
RequireJS.require([
'js/bookmarks/views/bookmarks_button'
], function (BookmarksButton) {
'use strict';
return new BookmarksButton();
});
;(function (define) {
define(['backbone'], function (Backbone) {
'use strict';
return Backbone.Model.extend({
idAttribute: 'id',
defaults: {
course_id: '',
usage_id: '',
display_name: '',
path: [],
created: ''
},
blockUrl: function () {
return '/courses/' + this.get('course_id') + '/jump_to/' + this.get('usage_id');
}
});
});
})(define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['gettext', 'jquery', 'underscore', 'backbone', 'js/bookmarks/views/bookmarks_list',
'js/bookmarks/collections/bookmarks', 'js/views/message'],
function (gettext, $, _, Backbone, BookmarksListView, BookmarksCollection, MessageView) {
return Backbone.View.extend({
el: '.courseware-bookmarks-button',
loadingMessageElement: '#loading-message',
errorMessageElement: '#error-message',
events: {
'click .bookmarks-list-button': 'toggleBookmarksListView'
},
initialize: function () {
this.template = _.template($('#bookmarks_button-tpl').text());
this.bookmarksListView = new BookmarksListView({
collection: new BookmarksCollection(),
loadingMessageView: new MessageView({el: $(this.loadingMessageElement)}),
errorMessageView: new MessageView({el: $(this.errorMessageElement)})
});
},
toggleBookmarksListView: function () {
if (this.bookmarksListView.areBookmarksVisible()) {
this.bookmarksListView.hideBookmarks();
this.$('.bookmarks-list-button').attr('aria-pressed', 'false');
this.$('.bookmarks-list-button').removeClass('is-active').addClass('is-inactive');
} else {
this.bookmarksListView.showBookmarks();
this.$('.bookmarks-list-button').attr('aria-pressed', 'true');
this.$('.bookmarks-list-button').removeClass('is-inactive').addClass('is-active');
}
}
});
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['gettext', 'jquery', 'underscore', 'backbone', 'moment'],
function (gettext, $, _, Backbone, _moment) {
var moment = _moment || window.moment;
return Backbone.View.extend({
el: '.courseware-results',
coursewareContentEl: '#course-content',
errorIcon: '<i class="fa fa-fw fa-exclamation-triangle message-error" aria-hidden="true"></i>',
loadingIcon: '<i class="fa fa-fw fa-spinner fa-pulse message-in-progress" aria-hidden="true"></i>',
errorMessage: gettext('An error has occurred. Please try again.'),
loadingMessage: gettext('Loading'),
events : {
'click .bookmarks-results-list-item': 'visitBookmark'
},
initialize: function (options) {
this.template = _.template($('#bookmarks_list-tpl').text());
this.loadingMessageView = options.loadingMessageView;
this.errorMessageView = options.errorMessageView;
this.courseId = $(this.el).data('courseId');
this.langCode = $(this.el).data('langCode');
_.bindAll(this, 'render', 'humanFriendlyDate');
},
render: function () {
var data = {
bookmarks: this.collection.models,
humanFriendlyDate: this.humanFriendlyDate
};
this.$el.html(this.template(data));
this.delegateEvents();
return this;
},
showBookmarks: function () {
var view = this;
this.hideErrorMessage();
this.showBookmarksContainer();
this.showLoadingMessage();
this.collection.fetch({
reset: true,
data: {course_id: this.courseId, fields: 'display_name,path'}
}).done(function () {
view.hideLoadingMessage();
view.render();
view.focusBookmarksElement();
}).fail(function () {
view.hideLoadingMessage();
view.showErrorMessage();
});
},
visitBookmark: function (event) {
window.location = event.target.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.
*/
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.coursewareContentEl).show();
},
showBookmarksContainer: function () {
$(this.coursewareContentEl).hide();
// Empty el if it's not empty to get the clean state.
this.$el.html('');
this.$el.show();
},
showLoadingMessage: function () {
this.loadingMessageView.showMessage(this.loadingMessage, this.loadingIcon);
},
hideLoadingMessage: function () {
this.loadingMessageView.hideMessage();
},
showErrorMessage: function () {
this.errorMessageView.showMessage(this.errorMessage, this.errorIcon);
},
hideErrorMessage: function () {
this.errorMessageView.hideMessage();
},
focusBookmarksElement: function () {
this.$('#my-bookmarks').focus();
}
});
});
}).call(this, define || RequireJS.define);
<div class="courseware-bookmarks-button">
<button type="button" class="bookmarks-list-button is-inactive" aria-pressed="false">
Bookmarks
</button>
</div>
<section class="courseware-results-wrapper">
<div id="loading-message" aria-live="assertive" aria-relevant="all"></div>
<div id="error-message" aria-live="polite"></div>
<div class="courseware-results" data-course-id="a/b/c" data-lang-code="en"></div>
</section>
......@@ -72,6 +72,7 @@ define([
},
showLoadingMessage: function () {
this.doCleanup();
this.$el.html(this.loadingTemplate());
this.$el.show();
this.$contentElement.hide();
......@@ -83,6 +84,15 @@ define([
this.$contentElement.hide();
},
doCleanup: function () {
// Empty any loading/error message and empty the el
// Bookmarks share the same container element, So we are doing
// this to ensure that elements are in clean/initial state
$('#loading-message').html('');
$('#error-message').html('');
this.$el.html('');
},
loadNext: function (event) {
event && event.preventDefault();
this.$el.find(this.spinner).show();
......
RequireJS.require([
'jquery',
'backbone',
'js/search/course/search_app',
'js/search/base/routers/search_router',
'js/search/course/views/search_form',
'js/search/base/collections/search_collection',
'js/search/course/views/search_results_view'
], function ($, Backbone, SearchApp, SearchRouter, CourseSearchForm, SearchCollection, CourseSearchResultsView) {
'use strict';
var courseId = $('.courseware-results').data('courseId');
var app = new SearchApp(
courseId,
SearchRouter,
CourseSearchForm,
SearchCollection,
CourseSearchResultsView
);
Backbone.history.start();
});
......@@ -8,7 +8,7 @@ define([
return SearchResultsView.extend({
el: '#courseware-search-results',
el: '.courseware-results',
contentElement: '#course-content',
resultsTemplateId: '#course_search_results-tpl',
loadingTemplateId: '#search_loading-tpl',
......
define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/bookmarks/views/bookmarks_button'
],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, BookmarksButtonView) {
'use strict';
describe("lms.courseware.bookmarks", function () {
var bookmarksButtonView;
var BOOKMARKS_API_URL = '/api/bookmarks/v0/bookmarks/';
beforeEach(function () {
loadFixtures('js/fixtures/bookmarks/bookmarks.html');
TemplateHelpers.installTemplates(
[
'templates/message_view',
'templates/bookmarks/bookmarks_list'
]
);
bookmarksButtonView = new BookmarksButtonView();
this.addMatchers({
toHaveBeenCalledWithUrl: function (expectedUrl) {
return expectedUrl === this.actual.argsForCall[0][0].target.pathname;
}
});
});
var createBookmarksData = function (count) {
var data = {
results: []
};
for(var i = 0; i < count; 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,
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(' <i class="icon fa fa-caret-right" aria-hidden="true"></i><span class="sr">-</span> ');
};
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 b = 0; b < results.length; b++) {
courseId = results[b].course_id;
usageId = results[b].usage_id;
expect(bookmarks[b]).toHaveAttr('href', createBookmarkUrl(courseId, usageId));
expect($(bookmarks[b]).find('.list-item-breadcrumbtrail').html().trim()).
toBe(breadcrumbTrail(results[b].path, results[b].display_name));
expect($(bookmarks[b]).find('.list-item-date').text().trim()).
toBe('Bookmarked on ' + view.humanFriendlyDate(results[b].created));
}
};
it("has correct behavior for bookmarks button", function () {
var requests = AjaxHelpers.requests(this);
spyOn(bookmarksButtonView, 'toggleBookmarksListView').andCallThrough();
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(1));
bookmarksButtonView.$('.bookmarks-list-button').click();
expect(bookmarksButtonView.$('.bookmarks-list-button')).toHaveAttr('aria-pressed', 'false');
expect(bookmarksButtonView.$('.bookmarks-list-button')).toHaveClass('is-inactive');
});
it("has rendered empty bookmarks list correctly", function () {
var requests = AjaxHelpers.requests(this);
var expectedData = createBookmarksData(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);
});
it("has rendered bookmarked list correctly", function () {
var requests = AjaxHelpers.requests(this);
var url = BOOKMARKS_API_URL + '?course_id=COURSE_ID&fields=display_name%2Cpath';
var expectedData = createBookmarksData(3);
spyOn(bookmarksButtonView.bookmarksListView, 'courseId').andReturn('COURSE_ID');
bookmarksButtonView.$('.bookmarks-list-button').click();
expect($('#loading-message').text().trim()).
toBe(bookmarksButtonView.bookmarksListView.loadingMessage);
AjaxHelpers.expectRequest(requests, 'GET', url);
AjaxHelpers.respondWithJson(requests, expectedData);
expect(bookmarksButtonView.bookmarksListView.$('.bookmarks-results-header').text().trim()).
toBe('My Bookmarks');
verifyBookmarkedData(bookmarksButtonView.bookmarksListView, expectedData);
});
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(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);
});
});
});
......@@ -66,6 +66,7 @@
'_split': 'js/split',
'mathjax_delay_renderer': 'coffee/src/mathjax_delay_renderer',
'MathJaxProcessor': 'coffee/src/customwmd',
'moment': 'xmodule_js/common_static/js/src/moment',
// Manually specify LMS files that are not converted to RequireJS
'history': 'js/vendor/history',
......@@ -733,7 +734,11 @@
'lms/include/teams/js/spec/views/topic_teams_spec.js',
'lms/include/teams/js/spec/views/topics_spec.js',
'lms/include/teams/js/spec/views/team_profile_header_actions_spec.js',
'lms/include/js/spec/financial-assistance/financial_assistance_form_view_spec.js'
'lms/include/js/spec/financial-assistance/financial_assistance_form_view_spec.js',
'lms/include/teams/js/spec/views/team_join_spec.js'
'lms/include/js/spec/discovery/discovery_spec.js',
'lms/include/js/spec/ccx/schedule_spec.js',
'lms/include/js/spec/bookmarks/bookmarks_spec.js'
]);
}).call(this, requirejs, define);
......@@ -361,20 +361,90 @@ define([
expect($('.search-button')).toBeVisible();
}
describe('CourseSearchForm', function () {
beforeEach(function () {
loadFixtures('js/fixtures/search/course_search_form.html');
this.form = new CourseSearchForm();
this.onClear = jasmine.createSpy('onClear');
this.onSearch = jasmine.createSpy('onSearch');
this.form.on('clear', this.onClear);
this.form.on('search', this.onSearch);
});
it('trims input string', trimsInputString);
it('handles calls to doSearch', doesSearch);
it('triggers a search event and changes to active state', triggersSearchEvent);
it('clears search when clicking on cancel button', clearsSearchOnCancel);
it('clears search when search box is empty', clearsSearchOnEmpty);
function rendersSearchResults () {
var searchResults = [{
location: ['section', 'subsection', 'unit'],
url: '/some/url/to/content',
content_type: 'text',
course_name: '',
excerpt: 'this is a short excerpt'
}];
this.collection.set(searchResults);
this.collection.latestModelsCount = 1;
this.collection.totalCount = 1;
this.resultsView.render();
expect(this.resultsView.$el.find('ol')[0]).toExist();
expect(this.resultsView.$el.find('li').length).toEqual(1);
expect(this.resultsView.$el).toContainHtml('Search Results');
expect(this.resultsView.$el).toContainHtml('this is a short excerpt');
this.collection.set(searchResults);
this.collection.totalCount = 2;
this.resultsView.renderNext();
expect(this.resultsView.$el.find('.search-count')).toContainHtml('2');
expect(this.resultsView.$el.find('li').length).toEqual(2);
}
function showsMoreResultsLink () {
this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return true; };
this.resultsView.render();
expect(this.resultsView.$el.find('a.search-load-next')[0]).toExist();
this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return false; };
this.resultsView.render();
expect(this.resultsView.$el.find('a.search-load-next')[0]).not.toExist();
}
function triggersNextPageEvent () {
var onNext = jasmine.createSpy('onNext');
this.resultsView.on('next', onNext);
this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return true; };
this.resultsView.render();
this.resultsView.$el.find('a.search-load-next').click();
expect(onNext).toHaveBeenCalled();
}
function showsLoadMoreSpinner () {
this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return true; };
this.resultsView.render();
expect(this.resultsView.$el.find('a.search-load-next .icon')).toBeHidden();
this.resultsView.loadNext();
// toBeVisible does not work with inline
expect(this.resultsView.$el.find('a.search-load-next .icon')).toHaveCss({ 'display': 'inline' });
this.resultsView.renderNext();
expect(this.resultsView.$el.find('a.search-load-next .icon')).toBeHidden();
}
function beforeEachHelper(SearchResultsView) {
appendSetFixtures(
'<section id="courseware-search-results"></section>' +
'<section id="course-content"></section>' +
'<section id="dashboard-search-results"></section>' +
'<section id="my-courses"></section>'
);
TemplateHelpers.installTemplates([
'templates/search/course_search_item',
'templates/search/dashboard_search_item',
'templates/search/course_search_results',
'templates/search/dashboard_search_results',
'templates/search/search_list',
'templates/search/search_loading',
'templates/search/search_error'
]);
var MockCollection = Backbone.Collection.extend({
hasNextPage: function () {},
latestModelsCount: 0,
pageSize: 20,
latestModels: function () {
return SearchCollection.prototype.latestModels.apply(this, arguments);
}
});
describe('DashSearchForm', function () {
......@@ -492,7 +562,7 @@ define([
'<section id="dashboard-search-results"></section>' +
'<section id="my-courses"></section>'
);
TemplateHelpers.installTemplates([
'templates/search/course_search_item',
'templates/search/dashboard_search_item',
......@@ -503,6 +573,12 @@ define([
'templates/search/search_error'
]);
var courseId = 'a/b/c';
CourseSearchFactory(courseId);
spyOn(Backbone.history, 'navigate');
this.$contentElement = $('#course-content');
this.$searchResults = $('.courseware-results');
var MockCollection = Backbone.Collection.extend({
hasNextPage: function () {},
latestModelsCount: 0,
......
......@@ -8,7 +8,7 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
'js/student_profile/views/learner_profile_view',
'js/student_profile/views/learner_profile_fields',
'js/student_profile/views/learner_profile_factory',
'js/views/message_banner'
'js/views/message'
],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews,
UserAccountModel, UserPreferencesModel, LearnerProfileView, LearnerProfileFields, LearnerProfilePage) {
......
......@@ -2,10 +2,10 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
'js/spec/student_account/helpers',
'js/student_account/models/user_account_model',
'js/student_profile/views/learner_profile_fields',
'js/views/message_banner'
'js/views/message'
],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, UserAccountModel, LearnerProfileFields,
MessageBannerView) {
MessageView) {
'use strict';
describe("edx.user.LearnerProfileFields", function () {
......@@ -31,8 +31,9 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
accountSettingsModel.url = Helpers.USER_ACCOUNTS_API_URL;
var messageView = new MessageBannerView({
el: $('.message-banner')
var messageView = new MessageView({
el: $('.message-banner'),
templateId: '#message_banner-tpl'
});
return new LearnerProfileFields.ProfileImageFieldView({
......
......@@ -7,11 +7,11 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
'js/student_profile/views/learner_profile_fields',
'js/student_profile/views/learner_profile_view',
'js/student_account/views/account_settings_fields',
'js/views/message_banner'
'js/views/message'
],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews,
UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView,
AccountSettingsFieldViews, MessageBannerView) {
AccountSettingsFieldViews, MessageView) {
'use strict';
describe("edx.user.LearnerProfileView", function () {
......@@ -45,8 +45,9 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
accountSettingsPageUrl: '/account/settings/'
});
var messageView = new MessageBannerView({
el: $('.message-banner')
var messageView = new MessageView({
el: $('.message-banner'),
templateId: '#message_banner-tpl'
});
var profileImageFieldView = new LearnerProfileFields.ProfileImageFieldView({
......
define(['backbone', 'jquery', 'underscore', 'js/views/message_banner'
define(['backbone', 'jquery', 'underscore', 'js/views/message', 'js/common_helpers/template_helpers'
],
function (Backbone, $, _, MessageBannerView) {
function (Backbone, $, _, MessageView, TemplateHelpers) {
'use strict';
describe("MessageBannerView", function () {
describe("MessageView", function () {
var messageEl = '.message-banner';
beforeEach(function () {
setFixtures('<div class="message-banner"></div>');
TemplateHelpers.installTemplate("templates/fields/message_banner");
TemplateHelpers.installTemplate("templates/message_view");
});
it('renders message correctly', function() {
var messageSelector = '.message-banner';
var messageView = new MessageBannerView({
el: $(messageSelector)
var createMessageView = function (messageContainer, templateId) {
return new MessageView({
el: $(messageContainer),
templateId: templateId
});
};
it('renders correctly with the /fields/message_banner template', function() {
var messageView = createMessageView(messageSelector, '#message_banner-tpl');
messageView.showMessage('I am message view');
// Verify error message
expect($(messageSelector).text().trim()).toBe('I am message view');
expect($(messageEl).text().trim()).toBe('I am message view');
messageView.hideMessage();
expect($(messageEl).text().trim()).toBe('');
});
it('renders correctly with the /message_view template', function() {
var messageView = createMessageView(messageEl, '#message-tpl');
var icon = '<i class="fa fa-thumbs-up"></i>';
messageView.showMessage('I am message view', icon);
expect($(messageEl).text().trim()).toBe('I am message view');
expect($(messageEl).html()).toContain(icon);
messageView.hideMessage();
expect($(messageSelector).text().trim()).toBe('');
expect($(messageEl).text().trim()).toBe('');
});
});
});
......@@ -8,10 +8,10 @@
'js/student_profile/views/learner_profile_fields',
'js/student_profile/views/learner_profile_view',
'js/student_account/views/account_settings_fields',
'js/views/message_banner',
'js/views/message',
'string_utils'
], function (gettext, $, _, Backbone, Logger, AccountSettingsModel, AccountPreferencesModel, FieldsView,
LearnerProfileFieldsView, LearnerProfileView, AccountSettingsFieldViews, MessageBannerView) {
LearnerProfileFieldsView, LearnerProfileView, AccountSettingsFieldViews, MessageView) {
return function (options) {
......@@ -36,8 +36,9 @@
var editable = options.own_profile ? 'toggle' : 'never';
var messageView = new MessageBannerView({
el: $('.message-banner')
var messageView = new MessageView({
el: $('.message-banner'),
templateId: '#message_banner-tpl'
});
var accountPrivacyFieldView = new LearnerProfileFieldsView.AccountPrivacyFieldView({
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -17,15 +17,17 @@
if (_.isUndefined(this.message) || _.isNull(this.message)) {
this.$el.html('');
} else {
this.$el.html(_.template(messageBannerTemplate, _.extend(this.options, {
message: this.message
})));
this.$el.html(this.template({
message: this.message,
icon: this.icon
}));
}
return this;
},
showMessage: function (message) {
showMessage: function (message, icon) {
this.message = message;
this.icon = icon;
this.render();
},
......
......@@ -114,6 +114,9 @@ fixture_paths:
- common/templates
- teams/templates
- support/templates
- js/fixtures/bookmarks
- templates/bookmarks
- templates/message_view.underscore
requirejs:
paths:
......
;(function (require, define) {
var paths = {}, config;
// jquery, underscore, gettext, URI, tinymce, or jquery.tinymce may already
// have been loaded and we do not want to load them a second time. Check if
// it is the case and use the global var instead.
if (window.jQuery) {
define("jquery", [], function() {return window.jQuery;});
} else {
paths.jquery = "js/vendor/jquery.min";
}
if (window._) {
define("underscore", [], function() {return window._;});
} else {
paths.jquery = "js/vendor/underscore-min";
}
if (window.gettext) {
define("gettext", [], function() {return window.gettext;});
} else {
paths.gettext = "/i18n";
}
if (window.Logger) {
define("logger", [], function() {return window.Logger;});
} else {
paths.logger = "js/src/logger";
}
if (window.URI) {
define("URI", [], function() {return window.URI;});
} else {
paths.URI = "js/vendor/URI.min";
}
if (window.tinymce) {
define('tinymce', [], function() {return window.tinymce;});
} else {
paths.tinymce = "js/vendor/tinymce/js/tinymce/tinymce.full.min";
}
if (window.jquery && window.jquery.tinymce) {
define("jquery.tinymce", [], function() {return window.jquery.tinymce;});
} else {
paths.tinymce = "js/vendor/tinymce/js/tinymce/jquery.tinymce.min";
}
config = {
// NOTE: baseUrl has been previously set in lms/static/templates/main.html
waitSeconds: 60,
paths: {
"annotator_1.2.9": "js/vendor/edxnotes/annotator-full.min",
"date": "js/vendor/date",
"text": 'js/vendor/requirejs/text',
"backbone": "js/vendor/backbone-min",
"backbone-super": "js/vendor/backbone-super",
"backbone.paginator": "js/vendor/backbone.paginator.min",
"underscore.string": "js/vendor/underscore.string.min",
// Files needed by OVA
"annotator": "js/vendor/ova/annotator-full",
"annotator-harvardx": "js/vendor/ova/annotator-full-firebase-auth",
"video.dev": "js/vendor/ova/video.dev",
"vjs.youtube": 'js/vendor/ova/vjs.youtube',
"rangeslider": 'js/vendor/ova/rangeslider',
"share-annotator": 'js/vendor/ova/share-annotator',
"richText-annotator": 'js/vendor/ova/richText-annotator',
"reply-annotator": 'js/vendor/ova/reply-annotator',
"grouping-annotator": 'js/vendor/ova/grouping-annotator',
"tags-annotator": 'js/vendor/ova/tags-annotator',
"diacritic-annotator": 'js/vendor/ova/diacritic-annotator',
"flagging-annotator": 'js/vendor/ova/flagging-annotator',
"jquery-Watch": 'js/vendor/ova/jquery-Watch',
"openseadragon": 'js/vendor/ova/openseadragon',
"osda": 'js/vendor/ova/OpenSeaDragonAnnotation',
"ova": 'js/vendor/ova/ova',
"catch": 'js/vendor/ova/catch/js/catch',
"handlebars": 'js/vendor/ova/catch/js/handlebars-1.1.2',
"moment": "js/vendor/moment-with-locales.min"
// end of files needed by OVA
},
shim: {
"annotator_1.2.9": {
deps: ["jquery"],
exports: "Annotator"
},
"date": {
exports: "Date"
},
"jquery": {
exports: "$"
},
"underscore": {
exports: "_"
},
"backbone": {
deps: ["underscore", "jquery"],
exports: "Backbone"
},
"backbone.paginator": {
deps: ["backbone"],
exports: "Backbone.Paginator"
},
"backbone-super": {
deps: ["backbone"]
},
"logger": {
exports: "Logger"
},
// Needed by OVA
"video.dev": {
exports:"videojs"
},
"vjs.youtube": {
deps: ["video.dev"]
},
"rangeslider": {
deps: ["video.dev"]
},
"annotator": {
exports: "Annotator"
},
"annotator-harvardx":{
deps: ["annotator"]
},
"share-annotator": {
deps: ["annotator"]
},
"richText-annotator": {
deps: ["annotator", "tinymce"]
},
"reply-annotator": {
deps: ["annotator"]
},
"tags-annotator": {
deps: ["annotator"]
},
"diacritic-annotator": {
deps: ["annotator"]
},
"flagging-annotator": {
deps: ["annotator"]
},
"grouping-annotator": {
deps: ["annotator"]
},
"ova": {
exports: "ova",
deps: [
"annotator", "annotator-harvardx", "video.dev", "vjs.youtube", "rangeslider", "share-annotator",
"richText-annotator", "reply-annotator", "tags-annotator", "flagging-annotator",
"grouping-annotator", "diacritic-annotator", "jquery-Watch", "catch", "handlebars", "URI"
]
},
"osda": {
exports: "osda",
deps: [
"annotator", "annotator-harvardx", "video.dev", "vjs.youtube", "rangeslider", "share-annotator",
"richText-annotator", "reply-annotator", "tags-annotator", "flagging-annotator",
"grouping-annotator", "diacritic-annotator", "openseadragon", "jquery-Watch", "catch", "handlebars",
"URI"
]
}
// End of needed by OVA
}
};
for (var key in paths) {
if ({}.hasOwnProperty.call(paths, key)) {
config.paths[key] = paths[key];
}
}
require.config(config);
}).call(this, require || RequireJS.require, define || RequireJS.define);
......@@ -54,6 +54,7 @@
@import 'views/homepage';
@import 'views/support';
@import "views/financial-assistance";
@import 'views/bookmarks';
@import 'course/auto-cert';
// app - discussion
......
......@@ -649,3 +649,11 @@ section.self-assessment {
font-weight: bold;
}
}
.courseware-results-wrapper {
padding: ($baseline*2);
.courseware-results {
display: none;
}
}
......@@ -2,7 +2,6 @@
@include box-sizing(border-box);
position: relative;
padding: ($baseline/4);
.search-field-wrapper {
position: relative;
......@@ -124,7 +123,7 @@
}
.courseware-search-bar {
box-shadow: 0 1px 0 $white inset, 0 -1px 0 $shadow-l1 inset;
width: flex-grid(7);
}
......@@ -171,6 +170,3 @@
}
}
.courseware-search-results {
padding: ($baseline*2);
}
// 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;
button {
&.is-active {
background-color: $white;
&:before {
content: "\f02e";
font-family: FontAwesome;
}
}
&.is-inactive {
background: none;
&:before {
content: "\f097";
font-family: FontAwesome;
}
}
}
.bookmarks-list-button {
background: none;
border-radius: ($baseline/4);
padding: ($baseline/2);
width: 100%;
&:hover {
background: none;
}
&:focus, &:active {
box-shadow: none;
}
}
}
// Rules for Bookmarks Results Header
.bookmarks-results-header {
margin: 0;
}
// Rules for Bookmarks Results
.bookmarks-results-list {
padding-top: $baseline;
.bookmarks-results-list-item {
@include padding(($baseline/4), $baseline, ($baseline/2), $baseline);
display: block;
border: 1px solid $gray;
border-radius: ($baseline/4);
margin-bottom: $baseline;
&:hover {
border-color: $link-color;
color: $link-color;
}
}
.results-list-item-view {
@include float(right);
margin-top: $baseline;
}
.list-item-date {
@extend %t-title7;
color: $gray;
}
a.bookmarks-results-list-item:before {
content: "\f02e";
position: relative;
top: -7px;
font-family: FontAwesome;
}
.list-item-content {
overflow: hidden;
}
.list-item-left-section {
@include float(left);
width: 90%;
.list-item-breadcrumbtrail, .list-item-date {
@extend %t-ultralight;
}
}
.list-item-right-section {
@include float(right);
margin-top: 7px;
}
}
// 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;
}
\ No newline at end of file
<div id="my-bookmarks" class="sr-is-focusable" tabindex="-1"></div>
<h2 class="bookmarks-results-header"><%= gettext("My Bookmarks") %></h2>
<% if (bookmarks.length) { %>
<div class='bookmarks-results-list'>
<% _.each(bookmarks, function(bookmark, index) { %>
<a class="bookmarks-results-list-item" href="<%= bookmark.blockUrl() %>" aria-labelledby="bookmark-link-<%= index %>" aria-describedby="bookmark-type-<%= index %> bookmark-date-<%= index %>">
<div class="list-item-content">
<div class="list-item-left-section">
<h3 id="bookmark-link-<%= index %>" class="list-item-breadcrumbtrail"> <%= _.pluck(bookmark.get('path'), 'display_name').concat([bookmark.get('display_name')]).join(' <i class="icon fa fa-caret-right" aria-hidden="true"></i><span class="sr">-</span> ') %> </h3>
<p id="bookmark-date-<%= index %>" class="list-item-date"> <%= gettext("Bookmarked on") %> <%= humanFriendlyDate(bookmark.get('created')) %> </p>
</div>
<p id="bookmark-type-<%= index %>" class="list-item-right-section">
<span aria-hidden="true"><%= gettext("View") %></span>
<i class="icon fa fa-arrow-right" aria-hidden="true"></i>
</p>
</div>
</a>
<% }); %>
</div>
<% } else {%>
<div class="bookmarks-empty" tabindex="0">
<div class="bookmarks-empty-header">
<i class="icon fa fa-bookmark-o bookmarks-empty-header-icon" aria-hidden="true"></i>
<%= gettext("You have not bookmarked any courseware pages yet.") %>
<br>
</div>
<div class="bookmarks-empty-detail">
<span class="bookmarks-empty-detail-title">
<%= 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.") %>
</span>
</div>
</div>
<% } %>
......@@ -46,6 +46,18 @@ ${page_title_breadcrumbs(course_name())}
% endfor
% endif
% for template_name in ["bookmarks_list"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="bookmarks/${template_name}.underscore" />
</script>
% endfor
% for template_name in ["message_view"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="headextra">
......@@ -131,17 +143,41 @@ ${fragment.foot_html()}
<input id="course-search-input" type="text" class="search-field"/>
<button type="submit" class="search-button">
${_('search')} <i class="icon fa fa-search" aria-hidden="true"></i>
<header id="open_close_accordion">
<a href="#">${_("close")}</a>
</header>
<div class="wrapper-course-modes">
<div class="courseware-bookmarks-button">
<button type="button" class="bookmarks-list-button is-inactive" aria-pressed="false">
${_('Bookmarks')}
</button>
<button type="button" class="cancel-button" aria-label="${_('Clear search')}">
<i class="icon fa fa-remove" aria-hidden="true"></i>
</button>
</div>
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
<div id="courseware-search-bar" class="search-bar courseware-search-bar" role="search" aria-label="Course">
<form>
<label for="course-search-input" class="sr">${_('Course Search')}</label>
<div class="search-field-wrapper">
<input id="course-search-input" type="text" class="search-field"/>
<button type="submit" class="search-button">
${_('search')} <i class="icon fa fa-search" aria-hidden="true"></i>
</button>
<button type="button" class="cancel-button" aria-label="${_('Clear search')}">
<i class="icon fa fa-remove" aria-hidden="true"></i>
</button>
</div>
</form>
</div>
</form>
</div>
% endif
% endif
<div class="accordion">
<nav class="course-navigation" aria-label="${_('Course')}">
</div>
<div id="accordion" style="display: none">
<nav aria-label="${_('Course Navigation')}">
% if accordion.strip():
${accordion}
% else:
......@@ -185,10 +221,13 @@ ${fragment.foot_html()}
${fragment.body_html()}
</section>
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
<section id="courseware-search-results" class="search-results courseware-search-results" data-course-id="${course.id}">
<section class="courseware-results-wrapper">
<div id="loading-message" aria-live="polite" aria-relevant="all"></div>
<div id="error-message" aria-live="polite"></div>
<div class="courseware-results search-results" data-course-id="${course.id}" data-lang-code="${language_preference}"></div>
</section>
% endif
</div>
</div>
<div class="container-footer">
......
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