Convert course bookmarks into a feature

LEARNER-39
This commit is contained in:
Andy Armstrong
2017-03-09 18:20:34 -05:00
parent 596634ebd6
commit 89e5c0fd8d
46 changed files with 1079 additions and 668 deletions

View File

@@ -0,0 +1,11 @@
<div class="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="bookmark-text">Bookmark this page</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,23 @@
<div class="course-view container" id="course-container">
<header class="page-header has-secondary">
<div class="page-header-main">
<nav aria-label="Discussions" class="sr-is-focusable" tabindex="-1">
<div class="has-breadcrumbs"><div class="breadcrumbs">
<span class="nav-item">
<a href="/courses/course-v1:test-course/course/">Course</a>
</span>
<span class="icon fa fa-angle-right" aria-hidden="true"></span>
<span class="nav-item">My Bookmarks</span>
</div>
</div>
</nav>
</div>
</header>
<div class="page-content">
<div class="course-bookmarks courseware-results-wrapper" id="main">
<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-v1:test-course" data-lang-code="en"></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,28 @@
(function(define) {
'use strict';
define([
'backbone',
'edx-ui-toolkit/js/pagination/paging-collection',
'course_bookmarks/js/models/bookmark'
], function(Backbone, PagingCollection, BookmarkModel) {
return PagingCollection.extend({
model: BookmarkModel,
queryParams: {
course_id: function() { return this.options.course_id; },
fields: function() { return 'display_name,path'; }
},
url: function() {
return this.url;
},
constructor: function(models, options) {
this.options = options;
this.url = options.url;
PagingCollection.prototype.constructor.call(this, models, options);
}
});
});
}(define || RequireJS.define));

View File

@@ -0,0 +1,35 @@
(function(define) {
'use strict';
define(
[
'jquery',
'js/views/message_banner',
'course_bookmarks/js/collections/bookmarks',
'course_bookmarks/js/views/bookmarks_list'
],
function($, MessageBannerView, BookmarksCollection, BookmarksListView) {
return function(options) {
var courseId = options.courseId,
bookmarksApiUrl = options.bookmarksApiUrl,
bookmarksCollection = new BookmarksCollection([],
{
course_id: courseId,
url: bookmarksApiUrl
}
);
var bookmarksView = new BookmarksListView(
{
$el: options.$el,
collection: bookmarksCollection,
loadingMessageView: new MessageBannerView({el: $('#loading-message')}),
errorMessageView: new MessageBannerView({el: $('#error-message')})
}
);
bookmarksView.render();
bookmarksView.showBookmarks();
return bookmarksView;
};
}
);
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,19 @@
(function(define) {
'use strict';
define(['backbone'], function(Backbone) {
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));

View File

@@ -0,0 +1,162 @@
define(['backbone', 'jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'common/js/spec_helpers/template_helpers', 'course_bookmarks/js/views/bookmark_button'
],
function(Backbone, $, _, AjaxHelpers, TemplateHelpers, BookmarkButtonView) {
'use strict';
describe('BookmarkButtonView', function() {
var createBookmarkButtonView, verifyBookmarkButtonState;
var API_URL = 'bookmarks/api/v1/bookmarks/';
beforeEach(function() {
loadFixtures('course_bookmarks/fixtures/bookmark_button.html');
TemplateHelpers.installTemplates(
[
'templates/fields/message_banner'
]
);
jasmine.createSpy('timerCallback');
jasmine.clock().install();
});
afterEach(function() {
jasmine.clock().uninstall();
});
createBookmarkButtonView = function(isBookmarked) {
return new BookmarkButtonView({
el: '.bookmark-button',
bookmarked: isBookmarked,
bookmarkId: 'bilbo,usage_1',
usageId: 'usage_1',
apiUrl: API_URL
});
};
verifyBookmarkButtonState = function(view, bookmarked) {
if (bookmarked) {
expect(view.$el).toHaveAttr('aria-pressed', 'true');
expect(view.$el).toHaveClass('bookmarked');
} else {
expect(view.$el).toHaveAttr('aria-pressed', 'false');
expect(view.$el).not.toHaveClass('bookmarked');
}
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);
var bookmarkedData = [[addBookmarkedData, removeBookmarkData], [removeBookmarkData, addBookmarkedData]];
_.each(bookmarkedData, function(actionsData) {
var firstActionData = actionsData[0];
var secondActionData = actionsData[1];
var bookmarkButtonView = createBookmarkButtonView(firstActionData.bookmarked);
verifyBookmarkButtonState(bookmarkButtonView, firstActionData.bookmarked);
spyOn(bookmarkButtonView, firstActionData.handler).and.callThrough();
spyOnEvent(bookmarkButtonView.$el, firstActionData.event);
bookmarkButtonView.$el.click();
expect(bookmarkButtonView.$el).toHaveAttr('disabled', 'disabled');
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].calls.reset();
expect(bookmarkButtonView.$el).not.toHaveAttr('disabled');
verifyBookmarkButtonState(bookmarkButtonView, secondActionData.bookmarked);
spyOn(bookmarkButtonView, secondActionData.handler).and.callThrough();
spyOnEvent(bookmarkButtonView.$el, secondActionData.event);
bookmarkButtonView.$el.click();
expect(bookmarkButtonView.$el).toHaveAttr('disabled', 'disabled');
AjaxHelpers.expectRequest(
requests,
secondActionData.method,
secondActionData.url,
secondActionData.body
);
expect(bookmarkButtonView[secondActionData.handler]).toHaveBeenCalled();
AjaxHelpers.respondWithJson(requests, {});
expect(secondActionData.event).toHaveBeenTriggeredOn(bookmarkButtonView.$el);
expect(bookmarkButtonView.$el).not.toHaveAttr('disabled');
verifyBookmarkButtonState(bookmarkButtonView, firstActionData.bookmarked);
bookmarkButtonView.undelegateEvents();
});
});
it('shows an error message for HTTP 500', function() {
var requests = AjaxHelpers.requests(this),
$messageBanner = $('.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);
});
it('removes error message after 5 seconds', function() {
var requests = AjaxHelpers.requests(this),
$messageBanner = $('.message-banner'),
bookmarkButtonView = createBookmarkButtonView(false);
bookmarkButtonView.$el.click();
AjaxHelpers.respondWithError(requests);
expect($messageBanner.text().trim()).toBe(bookmarkButtonView.errorMessage);
jasmine.clock().tick(5001);
expect($messageBanner.text().trim()).toBe('');
});
});
});

View File

@@ -0,0 +1,260 @@
define(['backbone',
'jquery',
'underscore',
'logger',
'URI',
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'common/js/spec_helpers/template_helpers',
'js/views/message_banner',
'course_bookmarks/js/spec_helpers/bookmark_helpers',
'course_bookmarks/js/views/bookmarks_list',
'course_bookmarks/js/collections/bookmarks'],
function(Backbone, $, _, Logger, URI, AjaxHelpers, TemplateHelpers, MessageBannerView,
BookmarkHelpers, BookmarksListView, BookmarksCollection) {
'use strict';
describe('BookmarksListView', function() {
var createBookmarksView, verifyRequestParams;
beforeEach(function() {
loadFixtures('course_bookmarks/fixtures/bookmarks.html');
TemplateHelpers.installTemplates([
'templates/fields/message_banner'
]);
spyOn(Logger, 'log').and.returnValue($.Deferred().resolve());
jasmine.addMatchers({
toHaveBeenCalledWithUrl: function() {
return {
compare: function(actual, expectedUrl) {
return {
pass: expectedUrl === actual.calls.mostRecent().args[0].currentTarget.pathname
};
}
};
}
});
});
createBookmarksView = function() {
var bookmarksCollection = new BookmarksCollection(
[],
{
course_id: BookmarkHelpers.TEST_COURSE_ID,
url: BookmarkHelpers.TEST_API_URL
}
);
var bookmarksView = new BookmarksListView({
$el: $('.course-bookmarks'),
collection: bookmarksCollection,
loadingMessageView: new MessageBannerView({el: $('#loading-message')}),
errorMessageView: new MessageBannerView({el: $('#error-message')})
});
return bookmarksView;
};
verifyRequestParams = function(requests, params) {
var urlParams = (new URI(requests[requests.length - 1].url)).query(true);
_.each(params, function(value, key) {
expect(urlParams[key]).toBe(value);
});
};
it('can correctly render an empty bookmarks list', function() {
var requests = AjaxHelpers.requests(this);
var bookmarksView = createBookmarksView();
var expectedData = BookmarkHelpers.createBookmarksData({numBookmarksToCreate: 0});
bookmarksView.showBookmarks();
AjaxHelpers.respondWithJson(requests, expectedData);
expect(bookmarksView.$('.bookmarks-empty-header').text().trim()).toBe(
'You have not bookmarked any courseware pages yet'
);
expect(bookmarksView.$('.bookmarks-empty-detail-title').text().trim()).toBe(
'Use bookmarks to help you easily return to courseware pages. ' +
'To bookmark a page, click on "Bookmark this page" beneath the unit title.'
);
expect(bookmarksView.$('.paging-header').length).toBe(0);
expect(bookmarksView.$('.paging-footer').length).toBe(0);
});
it('has rendered bookmarked list correctly', function() {
var requests = AjaxHelpers.requests(this);
var bookmarksView = createBookmarksView();
var expectedData = BookmarkHelpers.createBookmarksData({numBookmarksToCreate: 3});
bookmarksView.showBookmarks();
verifyRequestParams(
requests,
{
course_id: BookmarkHelpers.TEST_COURSE_ID,
fields: 'display_name,path',
page: '1',
page_size: '10'
}
);
AjaxHelpers.respondWithJson(requests, expectedData);
BookmarkHelpers.verifyBookmarkedData(bookmarksView, expectedData);
expect(bookmarksView.$('.paging-header').length).toBe(1);
expect(bookmarksView.$('.paging-footer').length).toBe(1);
});
it('calls bookmarks list render on page_changed event', function() {
var renderSpy = spyOn(BookmarksListView.prototype, 'render');
var listView = new BookmarksListView({
collection: new BookmarksCollection([], {
course_id: 'abc',
url: '/test-bookmarks/url/'
})
});
listView.collection.trigger('page_changed');
expect(renderSpy).toHaveBeenCalled();
});
it('can go to a page number', function() {
var requests = AjaxHelpers.requests(this);
var expectedData = BookmarkHelpers.createBookmarksData(
{
numBookmarksToCreate: 10,
count: 12,
num_pages: 2,
current_page: 1,
start: 0
}
);
var bookmarksView = createBookmarksView();
bookmarksView.showBookmarks();
AjaxHelpers.respondWithJson(requests, expectedData);
BookmarkHelpers.verifyBookmarkedData(bookmarksView, expectedData);
bookmarksView.$('input#page-number-input').val('2');
bookmarksView.$('input#page-number-input').trigger('change');
expectedData = BookmarkHelpers.createBookmarksData(
{
numBookmarksToCreate: 2,
count: 12,
num_pages: 2,
current_page: 2,
start: 10
}
);
AjaxHelpers.respondWithJson(requests, expectedData);
BookmarkHelpers.verifyBookmarkedData(bookmarksView, expectedData);
expect(bookmarksView.$('.paging-footer span.current-page').text().trim()).toBe('2');
expect(bookmarksView.$('.paging-header span').text().trim()).toBe('Showing 11-12 out of 12 total');
});
it('can navigate forward and backward', function() {
var requests = AjaxHelpers.requests(this);
var bookmarksView = createBookmarksView();
var expectedData = BookmarkHelpers.createBookmarksData(
{
numBookmarksToCreate: 10,
count: 15,
num_pages: 2,
current_page: 1,
start: 0
}
);
bookmarksView.showBookmarks();
BookmarkHelpers.verifyPaginationInfo(
requests,
bookmarksView,
expectedData,
'1',
'Showing 1-10 out of 15 total'
);
verifyRequestParams(
requests,
{
course_id: BookmarkHelpers.TEST_COURSE_ID,
fields: 'display_name,path',
page: '1',
page_size: '10'
}
);
bookmarksView.$('.paging-footer .next-page-link').click();
expectedData = BookmarkHelpers.createBookmarksData(
{
numBookmarksToCreate: 5,
count: 15,
num_pages: 2,
current_page: 2,
start: 10
}
);
BookmarkHelpers.verifyPaginationInfo(
requests,
bookmarksView,
expectedData,
'2',
'Showing 11-15 out of 15 total'
);
verifyRequestParams(
requests,
{
course_id: BookmarkHelpers.TEST_COURSE_ID,
fields: 'display_name,path',
page: '2',
page_size: '10'
}
);
expectedData = BookmarkHelpers.createBookmarksData(
{
numBookmarksToCreate: 10,
count: 15,
num_pages: 2,
current_page: 1,
start: 0
}
);
bookmarksView.$('.paging-footer .previous-page-link').click();
BookmarkHelpers.verifyPaginationInfo(
requests,
bookmarksView,
expectedData,
'1',
'Showing 1-10 out of 15 total'
);
verifyRequestParams(
requests,
{
course_id: BookmarkHelpers.TEST_COURSE_ID,
fields: 'display_name,path',
page: '1',
page_size: '10'
}
);
});
it('can navigate to correct url', function() {
var requests = AjaxHelpers.requests(this);
var bookmarksView = createBookmarksView();
var url;
spyOn(bookmarksView, 'visitBookmark');
bookmarksView.showBookmarks();
AjaxHelpers.respondWithJson(requests, BookmarkHelpers.createBookmarksData({numBookmarksToCreate: 1}));
bookmarksView.$('.bookmarks-results-list-item').click();
url = bookmarksView.$('.bookmarks-results-list-item').attr('href');
expect(bookmarksView.visitBookmark).toHaveBeenCalledWithUrl(url);
});
it('shows an error message for HTTP 500', function() {
var requests = AjaxHelpers.requests(this);
var bookmarksView = createBookmarksView();
bookmarksView.showBookmarks();
AjaxHelpers.respondWithError(requests);
expect($('#error-message').text().trim()).toBe(bookmarksView.errorMessage);
});
});
});

View File

@@ -0,0 +1,36 @@
define(['jquery',
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'course_bookmarks/js/spec_helpers/bookmark_helpers',
'course_bookmarks/js/course_bookmarks_factory'
],
function($, AjaxHelpers, BookmarkHelpers, CourseBookmarksFactory) {
'use strict';
describe('CourseBookmarksFactory', function() {
beforeEach(function() {
loadFixtures('course_bookmarks/fixtures/bookmarks.html');
});
it('can render the initial bookmarks', function() {
var requests = AjaxHelpers.requests(this),
expectedData = BookmarkHelpers.createBookmarksData(
{
numBookmarksToCreate: 10,
count: 15,
num_pages: 2,
current_page: 1,
start: 0
}
),
bookmarksView;
bookmarksView = CourseBookmarksFactory({
$el: $('.course-bookmarks'),
courseId: BookmarkHelpers.TEST_COURSE_ID,
bookmarksApiUrl: BookmarkHelpers.TEST_API_URL
});
BookmarkHelpers.verifyPaginationInfo(
requests, bookmarksView, expectedData, '1', 'Showing 1-10 out of 15 total'
);
});
});
});

View File

@@ -0,0 +1,93 @@
define(
[
'underscore',
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers'
],
function(_, AjaxHelpers) {
'use strict';
var TEST_COURSE_ID = 'course-v1:test-course';
var createBookmarksData = function(options) {
var data = {
count: options.count || 0,
num_pages: options.num_pages || 1,
current_page: options.current_page || 1,
start: options.start || 0,
results: []
},
i, bookmarkInfo;
for (i = 0; i < options.numBookmarksToCreate; i++) {
bookmarkInfo = {
id: i,
display_name: 'UNIT_DISPLAY_NAME_' + i,
created: new Date().toISOString(),
course_id: 'COURSE_ID',
usage_id: 'UNIT_USAGE_ID_' + i,
block_type: 'vertical',
path: [
{display_name: 'SECTION_DISPLAY_NAME', usage_id: 'SECTION_USAGE_ID'},
{display_name: 'SUBSECTION_DISPLAY_NAME', usage_id: 'SUBSECTION_USAGE_ID'}
]
};
data.results.push(bookmarkInfo);
}
return data;
};
var createBookmarkUrl = function(courseId, usageId) {
return '/courses/' + courseId + '/jump_to/' + usageId;
};
var breadcrumbTrail = function(path, unitDisplayName) {
return _.pluck(path, 'display_name').
concat([unitDisplayName]).
join(' <span class="icon fa fa-caret-right" aria-hidden="true"></span><span class="sr">-</span> ');
};
var verifyBookmarkedData = function(view, expectedData) {
var courseId, usageId;
var bookmarks = view.$('.bookmarks-results-list-item');
var results = expectedData.results;
var i, $bookmark;
expect(bookmarks.length, results.length);
for (i = 0; i < results.length; i++) {
$bookmark = $(bookmarks[i]);
courseId = results[i].course_id;
usageId = results[i].usage_id;
expect(bookmarks[i]).toHaveAttr('href', createBookmarkUrl(courseId, usageId));
expect($bookmark.data('bookmarkId')).toBe(i);
expect($bookmark.data('componentType')).toBe('vertical');
expect($bookmark.data('usageId')).toBe(usageId);
expect($bookmark.find('.list-item-breadcrumbtrail').html().trim())
.toBe(breadcrumbTrail(results[i].path, results[i].display_name));
expect($bookmark.find('.list-item-date').text().trim())
.toBe('Bookmarked on ' + view.humanFriendlyDate(results[i].created));
}
};
var verifyPaginationInfo = function(requests, view, expectedData, currentPage, headerMessage) {
AjaxHelpers.respondWithJson(requests, expectedData);
verifyBookmarkedData(view, expectedData);
expect(view.$('.paging-footer span.current-page').text().trim()).toBe(currentPage);
expect(view.$('.paging-header span').text().trim()).toBe(headerMessage);
};
return {
TEST_COURSE_ID: TEST_COURSE_ID,
TEST_API_URL: '/bookmarks/api',
createBookmarksData: createBookmarksData,
createBookmarkUrl: createBookmarkUrl,
verifyBookmarkedData: verifyBookmarkedData,
verifyPaginationInfo: verifyPaginationInfo
};
});

View File

@@ -0,0 +1,114 @@
(function(define) {
'use strict';
define(['gettext', 'jquery', 'underscore', 'backbone', 'js/views/message_banner'],
function(gettext, $, _, Backbone, MessageBannerView) {
return Backbone.View.extend({
errorMessage: gettext('An error has occurred. Please try again.'),
bookmarkText: gettext('Bookmark this page'),
bookmarkedText: gettext('Bookmarked'),
events: {
click: 'toggleBookmark'
},
showBannerInterval: 5000, // time in ms
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();
this.$el.prop('disabled', true);
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(jqXHR) {
var response, userMessage;
try {
response = jqXHR.responseText ? JSON.parse(jqXHR.responseText) : '';
userMessage = response ? response.user_message : '';
view.showError(userMessage);
} catch (err) {
view.showError();
}
},
complete: function() {
view.$el.prop('disabled', false);
}
});
},
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();
},
complete: function() {
view.$el.prop('disabled', false);
}
});
},
setBookmarkState: function(bookmarked) {
if (bookmarked) {
this.$el.addClass('bookmarked');
this.$el.attr('aria-pressed', 'true');
this.$el.find('.bookmark-text').text(this.bookmarkedText);
} else {
this.$el.removeClass('bookmarked');
this.$el.attr('aria-pressed', 'false');
this.$el.find('.bookmark-text').text(this.bookmarkText);
}
},
showError: function(errorText) {
var errorMsg = errorText || this.errorMessage;
if (!this.messageView) {
this.messageView = new MessageBannerView({
el: $('.message-banner'),
type: 'error'
});
}
this.messageView.showMessage(errorMsg);
// Hide message automatically after some interval
setTimeout(_.bind(function() {
this.messageView.hideMessage();
}, this), this.showBannerInterval);
}
});
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,124 @@
(function(define) {
'use strict';
define(['gettext', 'jquery', 'underscore', 'backbone', 'logger', 'moment', 'edx-ui-toolkit/js/utils/html-utils',
'common/js/components/views/paging_header', 'common/js/components/views/paging_footer',
'text!course_bookmarks/templates/bookmarks-list.underscore'
],
function(gettext, $, _, Backbone, Logger, _moment, HtmlUtils,
PagingHeaderView, PagingFooterView, bookmarksListTemplate) {
var moment = _moment || window.moment;
return Backbone.View.extend({
el: '.courseware-results',
coursewareContentEl: '#course-content',
coursewareResultsWrapperEl: '.courseware-results-wrapper',
errorIcon: '<span class="fa fa-fw fa-exclamation-triangle message-error" aria-hidden="true"></span>',
loadingIcon: '<span class="fa fa-fw fa-spinner fa-pulse message-in-progress" aria-hidden="true"></span>', // eslint-disable-line max-len
errorMessage: gettext('An error has occurred. Please try again.'),
loadingMessage: gettext('Loading'),
defaultPage: 1,
events: {
'click .bookmarks-results-list-item': 'visitBookmark'
},
initialize: function(options) {
this.template = HtmlUtils.template(bookmarksListTemplate);
this.loadingMessageView = options.loadingMessageView;
this.errorMessageView = options.errorMessageView;
this.langCode = $(this.el).data('langCode');
this.pagingHeaderView = new PagingHeaderView({collection: this.collection});
this.pagingFooterView = new PagingFooterView({collection: this.collection});
this.listenTo(this.collection, 'page_changed', this.render);
_.bindAll(this, 'render', 'humanFriendlyDate');
},
render: function() {
var data = {
bookmarksCollection: this.collection,
humanFriendlyDate: this.humanFriendlyDate
};
HtmlUtils.setHtml(this.$el, this.template(data));
this.pagingHeaderView.setElement(this.$('.paging-header')).render();
this.pagingFooterView.setElement(this.$('.paging-footer')).render();
this.delegateEvents();
return this;
},
showBookmarks: function() {
var view = this;
this.hideErrorMessage();
this.showBookmarksContainer();
this.collection.getPage(this.defaultPage).done(function() {
view.render();
view.focusBookmarksElement();
}).fail(function() {
view.showErrorMessage();
});
},
visitBookmark: function(event) {
var $bookmarkedComponent = $(event.currentTarget),
bookmarkId = $bookmarkedComponent.data('bookmarkId'),
componentUsageId = $bookmarkedComponent.data('usageId'),
componentType = $bookmarkedComponent.data('componentType');
Logger.log(
'edx.bookmark.accessed',
{
bookmark_id: bookmarkId,
component_type: componentType,
component_usage_id: componentUsageId
}
).always(function() {
window.location.href = event.currentTarget.pathname;
});
},
/**
* Convert ISO 8601 formatted date into human friendly format.
*
* e.g, `2014-05-23T14:00:00Z` to `May 23, 2014`
*
* @param {String} isoDate - ISO 8601 formatted date string.
*/
humanFriendlyDate: function(isoDate) {
moment.locale(this.langCode);
return moment(isoDate).format('LL');
},
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);

View File

@@ -0,0 +1,54 @@
<div id="my-bookmarks" class="sr-is-focusable" tabindex="-1"></div>
<% if (bookmarksCollection.length) { %>
<div class="paging-header"></div>
<div class='bookmarks-results-list'>
<% bookmarksCollection.each(function(bookmark, index) { %>
<a class="bookmarks-results-list-item"
href="<%- bookmark.blockUrl() %>"
aria-labelledby="bookmark-link-<%- index %>"
data-bookmark-id="<%- bookmark.get('id') %>"
data-component-type="<%- bookmark.get('block_type') %>"
data-usage-id="<%- bookmark.get('usage_id') %>"
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">
<%=
HtmlUtils.HTML(_.map(_.pluck(bookmark.get('path'), 'display_name'), _.escape)
.concat([_.escape(bookmark.get('display_name'))])
.join(' <span class="icon fa fa-caret-right" aria-hidden="true"></span><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>
<span class="icon fa fa-arrow-right" aria-hidden="true"></span>
</p>
</div>
</a>
<% }); %>
</div>
<div class="paging-footer"></div>
<% } else {%>
<div class="bookmarks-empty">
<h3 class="hd-4 bookmarks-empty-header">
<span class="icon fa fa-bookmark-o bookmarks-empty-header-icon" aria-hidden="true"></span>
<%- gettext("You have not bookmarked any courseware pages yet") %>
<br>
</h3>
<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, click on "Bookmark this page" beneath the unit title.') %>
</span>
</div>
</div>
<% } %>

View File

@@ -0,0 +1,11 @@
## mako
<%page expression_filter="h"/>
<%namespace name='static' file='../static_content.html'/>
<div class="course-bookmarks courseware-results-wrapper" id="main" tabindex="-1">
<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>
</div>

View File

@@ -0,0 +1,58 @@
## mako
<%! main_css = "style-main-v2" %>
<%page expression_filter="h"/>
<%inherit file="../main.html" />
<%namespace name='static' file='../static_content.html'/>
<%def name="online_help_token()"><% return "courseware" %></%def>
<%def name="course_name()">
<% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %>
</%def>
<%!
import json
from django.utils.translation import ugettext as _
from django.template.defaultfilters import escapejs
from django_comment_client.permissions import has_permission
from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
from openedx.core.djangolib.markup import HTML
%>
<%block name="bodyclass">course</%block>
<%block name="pagetitle">${course_name()}</%block>
<%include file="../courseware/course_navigation.html" args="active_page='courseware'" />
<%block name="head_extra">
${HTML(outline_fragment.head_html())}
</%block>
<%block name="footer_extra">
${HTML(outline_fragment.foot_html())}
</%block>
<%block name="content">
<div class="course-view container" id="course-container">
<header class="page-header has-secondary">
## Breadcrumb navigation
<div class="page-header-main">
<nav aria-label="Discussions" class="sr-is-focusable" tabindex="-1">
<div class="has-breadcrumbs"><div class="breadcrumbs">
<span class="nav-item">
<a href="${course_url}">Course</a>
</span>
<span class="icon fa fa-angle-right" aria-hidden="true"></span>
<span class="nav-item">My Bookmarks</span>
</div>
</div>
</nav>
</div>
</header>
<div class="page-content">
${HTML(outline_fragment.body_html())}
</div>
</div>
</%block>

View File

@@ -0,0 +1,15 @@
## mako
<%!
from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
%>
(function (require, define) {
require(['course_bookmarks/js/course_bookmarks_factory'], function (CourseBookmarksFactory) {
CourseBookmarksFactory({
$el: $(".course-bookmarks"),
courseId: '${unicode(course.id) | n, js_escaped_string}',
bookmarksApiUrl: '${bookmarks_api_url | n, js_escaped_string}',
});
});
}).call(this, require || RequireJS.require, define || RequireJS.define);

View File

@@ -0,0 +1,20 @@
"""
Defines URLs for the course experience.
"""
from django.conf.urls import url
from views.course_bookmarks import CourseBookmarksView, CourseBookmarksFragmentView
urlpatterns = [
url(
r'^$',
CourseBookmarksView.as_view(),
name='openedx.course_bookmarks.home',
),
url(
r'^bookmarks_fragment$',
CourseBookmarksFragmentView.as_view(),
name='openedx.course_bookmarks.course_bookmarks_fragment_view',
),
]

View File

@@ -0,0 +1,82 @@
"""
Views to show a course's bookmarks.
"""
from django.contrib.auth.decorators import login_required
from django.core.context_processors import csrf
from django.core.urlresolvers import reverse
from django.shortcuts import render_to_response
from django.template.loader import render_to_string
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import View
from courseware.courses import get_course_with_access
from lms.djangoapps.courseware.tabs import CoursewareTab
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
from util.views import ensure_valid_course_key
from web_fragments.fragment import Fragment
from xmodule.modulestore.django import modulestore
class CourseBookmarksView(View):
"""
The home page for a course.
"""
@method_decorator(login_required)
@method_decorator(ensure_csrf_cookie)
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True))
@method_decorator(ensure_valid_course_key)
def get(self, request, course_id):
"""
Displays the home page for the specified course.
Arguments:
request: HTTP request
course_id (unicode): course id
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
course_url_name = CoursewareTab.main_course_url_name(request)
course_url = reverse(course_url_name, kwargs={'course_id': unicode(course.id)})
# Render the bookmarks list as a fragment
outline_fragment = CourseBookmarksFragmentView().render_to_fragment(request, course_id=course_id)
# Render the entire unified course view
context = {
'csrf': csrf(request)['csrf_token'],
'course': course,
'course_url': course_url,
'outline_fragment': outline_fragment,
'disable_courseware_js': True,
'uses_pattern_library': True,
}
return render_to_response('course_bookmarks/course-bookmarks.html', context)
class CourseBookmarksFragmentView(EdxFragmentView):
"""
Fragment view that shows a user's bookmarks for a course.
"""
def render_to_fragment(self, request, course_id=None, **kwargs):
"""
Renders the course outline as a fragment.
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
context = {
'csrf': csrf(request)['csrf_token'],
'course': course,
'bookmarks_api_url': reverse('bookmarks'),
'language_preference': 'en', # TODO:
}
html = render_to_string('course_bookmarks/course-bookmarks-fragment.html', context)
inline_js = render_to_string('course_bookmarks/course_bookmarks_js.template', context)
fragment = Fragment(html)
self.add_fragment_resource_urls(fragment)
fragment.add_javascript(inline_js)
return fragment

View File

@@ -43,6 +43,9 @@ ${HTML(outline_fragment.foot_html())}
<a class="btn" href="${reverse('courseware', kwargs={'course_id': unicode(course.id.to_deprecated_string())})}">
${_("Resume Course")}
</a>
<a class="btn" href="${reverse('openedx.course_bookmarks.home', args=[course.id])}">
${_("Bookmarks")}
</a>
</div>
<div class="page-header-search">
<form class="search-form" role="search">

View File

@@ -1,5 +1,7 @@
## mako
<%page expression_filter="h"/>
<%namespace name='static' file='../static_content.html'/>
<%!