Commit 351e491c by Ben McMorran Committed by Daniel Friedman

Add generic paging framework

Authors:
  - Daniel Friedman
  - Ben McMorran
  - Peter Fogg

TNL-1892
parent 65a36a64
...@@ -252,6 +252,7 @@ define([ ...@@ -252,6 +252,7 @@ define([
"js/spec/views/xblock_string_field_editor_spec", "js/spec/views/xblock_string_field_editor_spec",
"js/spec/views/xblock_validation_spec", "js/spec/views/xblock_validation_spec",
"js/spec/views/license_spec", "js/spec/views/license_spec",
"js/spec/views/paging_spec",
"js/spec/views/utils/view_utils_spec", "js/spec/views/utils/view_utils_spec",
...@@ -284,4 +285,3 @@ define([ ...@@ -284,4 +285,3 @@ define([
# isolation issues with Squire.js # isolation issues with Squire.js
# "coffee/spec/views/assets_spec" # "coffee/spec/views/assets_spec"
]) ])
...@@ -33,6 +33,44 @@ define(["backbone.paginator", "js/models/asset"], function(BackbonePaginator, As ...@@ -33,6 +33,44 @@ define(["backbone.paginator", "js/models/asset"], function(BackbonePaginator, As
this.currentPage = currentPage; this.currentPage = currentPage;
this.start = start; this.start = start;
return response.assets; return response.assets;
},
setPage: function (page) {
var oldPage = this.currentPage,
self = this;
this.goTo(page - 1, {
reset: true,
success: function () {
self.trigger('page_changed');
},
error: function () {
self.currentPage = oldPage;
}
});
},
nextPage: function () {
if (this.currentPage < this.totalPages - 1) {
this.setPage(this.getPage() + 1);
}
},
previousPage: function () {
if (this.currentPage > 0) {
this.setPage(this.getPage() - 1);
}
},
getPage: function () {
return this.currentPage + 1;
},
hasPreviousPage: function () {
return this.currentPage > 0;
},
hasNextPage: function () {
return this.currentPage < this.totalPages - 1;
} }
}); });
return AssetCollection; return AssetCollection;
......
define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI", "js/views/asset", "js/views/assets", define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI", "js/views/assets",
"js/models/asset", "js/collections/asset", "js/spec_helpers/view_helpers"], "js/collections/asset", "js/spec_helpers/view_helpers"],
function ($, AjaxHelpers, URI, AssetView, AssetsView, AssetModel, AssetCollection, ViewHelpers) { function ($, AjaxHelpers, URI, AssetsView, AssetCollection, ViewHelpers) {
describe("Assets", function() { describe("Assets", function() {
var assetsView, mockEmptyAssetsResponse, mockAssetUploadResponse, mockFileUpload, var assetsView, mockEmptyAssetsResponse, mockAssetUploadResponse, mockFileUpload,
assetLibraryTpl, assetTpl, pagingFooterTpl, pagingHeaderTpl, uploadModalTpl; assetLibraryTpl, assetTpl, uploadModalTpl;
assetLibraryTpl = readFixtures('asset-library.underscore'); assetLibraryTpl = readFixtures('asset-library.underscore');
assetTpl = readFixtures('asset.underscore'); assetTpl = readFixtures('asset.underscore');
...@@ -357,6 +357,96 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI", "js/views/asset ...@@ -357,6 +357,96 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI", "js/views/asset
$(".upload-modal .file-chooser").fileupload('add', mockFileUpload); $(".upload-modal .file-chooser").fileupload('add', mockFileUpload);
expect(assetsView.largeFileErrorMsg).toBeNull(); expect(assetsView.largeFileErrorMsg).toBeNull();
}); });
describe('Paging footer', function () {
var firstPageAssets = {
sort: "uploadDate",
end: 1,
assets: [
{
"display_name": "test.jpg",
"url": "/c4x/A/CS102/asset/test.jpg",
"date_added": "Nov 07, 2014 at 17:47 UTC",
"id": "/c4x/A/CS102/asset/test.jpg",
"portable_url": "/static/test.jpg",
"thumbnail": "/c4x/A/CS102/thumbnail/test.jpg",
"locked": false,
"external_url": "localhost:8000/c4x/A/CS102/asset/test.jpg"
},
{
"display_name": "test.pdf",
"url": "/c4x/A/CS102/asset/test.pdf",
"date_added": "Oct 20, 2014 at 11:00 UTC",
"id": "/c4x/A/CS102/asset/test.pdf",
"portable_url": "/static/test.pdf",
"thumbnail": null,
"locked": false,
"external_url": "localhost:8000/c4x/A/CS102/asset/test.pdf"
}
],
pageSize: 2,
totalCount: 3,
start: 0,
page: 0
}, secondPageAssets = {
sort: "uploadDate",
end: 2,
assets: [
{
"display_name": "test.odt",
"url": "/c4x/A/CS102/asset/test.odt",
"date_added": "Oct 20, 2014 at 11:00 UTC",
"id": "/c4x/A/CS102/asset/test.odt",
"portable_url": "/static/test.odt",
"thumbnail": null,
"locked": false,
"external_url": "localhost:8000/c4x/A/CS102/asset/test.odt"
}
],
pageSize: 2,
totalCount: 3,
start: 2,
page: 1
};
it('can move forward a page using the next page button', function () {
var requests = AjaxHelpers.requests(this);
assetsView.pagingView.setPage(0);
AjaxHelpers.respondWithJson(requests, firstPageAssets);
expect(assetsView.pagingView.pagingFooter).toBeDefined();
expect(assetsView.pagingView.pagingFooter.$('button.next-page-link'))
.not.toHaveClass('is-disabled');
assetsView.pagingView.pagingFooter.$('button.next-page-link').click();
AjaxHelpers.respondWithJson(requests, secondPageAssets);
expect(assetsView.pagingView.pagingFooter.$('button.next-page-link'))
.toHaveClass('is-disabled');
});
it('can move back a page using the previous page button', function () {
var requests = AjaxHelpers.requests(this);
assetsView.pagingView.setPage(1);
AjaxHelpers.respondWithJson(requests, secondPageAssets);
expect(assetsView.pagingView.pagingFooter).toBeDefined();
expect(assetsView.pagingView.pagingFooter.$('button.previous-page-link'))
.not.toHaveClass('is-disabled');
assetsView.pagingView.pagingFooter.$('button.previous-page-link').click();
AjaxHelpers.respondWithJson(requests, firstPageAssets);
expect(assetsView.pagingView.pagingFooter.$('button.previous-page-link'))
.toHaveClass('is-disabled');
});
it('can set the current page using the page number input', function () {
var requests = AjaxHelpers.requests(this);
assetsView.pagingView.setPage(0);
AjaxHelpers.respondWithJson(requests, firstPageAssets);
assetsView.pagingView.pagingFooter.$('#page-number-input').val('2');
assetsView.pagingView.pagingFooter.$('#page-number-input').trigger('change');
AjaxHelpers.respondWithJson(requests, secondPageAssets);
expect(assetsView.collection.currentPage).toBe(1);
expect(assetsView.pagingView.pagingFooter.$('button.previous-page-link'))
.not.toHaveClass('is-disabled');
});
});
}); });
}); });
}); });
define(["jquery", "underscore", "common/js/spec_helpers/ajax_helpers", "URI", "js/models/xblock_info", define(["jquery", "underscore", "common/js/spec_helpers/ajax_helpers", "URI", "js/models/xblock_info",
"js/views/paged_container", "common/js/components/views/paging_header", "js/views/paged_container", "js/views/paging_header",
"common/js/components/views/paging_footer", "js/views/xblock"], "common/js/components/views/paging_footer", "js/views/xblock"],
function ($, _, AjaxHelpers, URI, XBlockInfo, PagedContainer, PagingHeader, PagingFooter, XBlockView) { function ($, _, AjaxHelpers, URI, XBlockInfo, PagedContainer, PagingHeader, PagingFooter, XBlockView) {
......
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/models/asset", "common/js/components/views/paging", define(["jquery", "underscore", "gettext", "js/views/baseview", "js/models/asset", "js/views/paging",
"js/views/asset", "common/js/components/views/paging_header", "common/js/components/views/paging_footer", "js/views/asset", "js/views/paging_header", "common/js/components/views/paging_footer",
"js/utils/modal", "js/views/utils/view_utils", "js/views/feedback_notification", "js/utils/modal", "js/views/utils/view_utils", "js/views/feedback_notification",
"text!templates/asset-library.underscore", "text!templates/asset-library.underscore",
"jquery.fileupload-process", "jquery.fileupload-validate"], "jquery.fileupload-process", "jquery.fileupload-validate"],
...@@ -71,7 +71,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/models/asset ...@@ -71,7 +71,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/models/asset
tableBody = this.$('#asset-table-body'); tableBody = this.$('#asset-table-body');
this.tableBody = tableBody; this.tableBody = tableBody;
this.pagingHeader = new PagingHeader({view: this, el: $('#asset-paging-header')}); this.pagingHeader = new PagingHeader({view: this, el: $('#asset-paging-header')});
this.pagingFooter = new PagingFooter({view: this, el: $('#asset-paging-footer')}); this.pagingFooter = new PagingFooter({collection: this.collection, el: $('#asset-paging-footer')});
this.pagingHeader.render(); this.pagingHeader.render();
this.pagingFooter.render(); this.pagingFooter.render();
......
define(["jquery", "underscore", "js/views/utils/view_utils", "js/views/container", "js/utils/module", "gettext", define(["jquery", "underscore", "js/views/utils/view_utils", "js/views/container", "js/utils/module", "gettext",
"js/views/feedback_notification", "common/js/components/views/paging_header", "js/views/feedback_notification", "js/views/paging_header", "common/js/components/views/paging_footer"],
"common/js/components/views/paging_footer", "common/js/components/views/paging_mixin"], function ($, _, ViewUtils, ContainerView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter) {
function ($, _, ViewUtils, ContainerView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter, PagingMixin) { var PagedContainerView = ContainerView.extend({
var PagedContainerView = ContainerView.extend(PagingMixin).extend({
initialize: function(options){ initialize: function(options){
var self = this; var self = this;
ContainerView.prototype.initialize.call(this); ContainerView.prototype.initialize.call(this);
...@@ -27,7 +26,33 @@ define(["jquery", "underscore", "js/views/utils/view_utils", "js/views/container ...@@ -27,7 +26,33 @@ define(["jquery", "underscore", "js/views/utils/view_utils", "js/views/container
// of paginator, on the current page. // of paginator, on the current page.
size: function() { return self.collection._size; }, size: function() { return self.collection._size; },
// Toggles the functionality for showing and hiding child previews. // Toggles the functionality for showing and hiding child previews.
showChildrenPreviews: true showChildrenPreviews: true,
// PagingFooter expects to be able to control paging through the collection instead of the view,
// so we just make these functions act as pass-throughs
setPage: function (page) {
self.setPage(page - 1);
},
nextPage: function () {
self.nextPage();
},
previousPage: function() {
self.previousPage();
},
getPage: function () {
return self.collection.currentPage + 1;
},
hasPreviousPage: function () {
return self.collection.currentPage > 0;
},
hasNextPage: function () {
return self.collection.currentPage < self.collection.totalPages - 1;
}
}; };
}, },
...@@ -87,6 +112,23 @@ define(["jquery", "underscore", "js/views/utils/view_utils", "js/views/container ...@@ -87,6 +112,23 @@ define(["jquery", "underscore", "js/views/utils/view_utils", "js/views/container
this.render(options); this.render(options);
}, },
nextPage: function() {
var collection = this.collection,
currentPage = collection.currentPage,
lastPage = collection.totalPages - 1;
if (currentPage < lastPage) {
this.setPage(currentPage + 1);
}
},
previousPage: function() {
var collection = this.collection,
currentPage = collection.currentPage;
if (currentPage > 0) {
this.setPage(currentPage - 1);
}
},
processPaging: function(options){ processPaging: function(options){
// We have the Django template sneak us the pagination information, // We have the Django template sneak us the pagination information,
// and we load it from a div here. // and we load it from a div here.
...@@ -119,7 +161,7 @@ define(["jquery", "underscore", "js/views/utils/view_utils", "js/views/container ...@@ -119,7 +161,7 @@ define(["jquery", "underscore", "js/views/utils/view_utils", "js/views/container
el: this.$el.find('.container-paging-header') el: this.$el.find('.container-paging-header')
}); });
this.pagingFooter = new PagingFooter({ this.pagingFooter = new PagingFooter({
view: this, collection: this.collection,
el: this.$el.find('.container-paging-footer') el: this.$el.find('.container-paging-footer')
}); });
......
define(["underscore", "backbone", "gettext", "text!templates/paging-header.underscore"],
function(_, Backbone, gettext, paging_header_template) {
var PagingHeader = Backbone.View.extend({
events : {
"click .next-page-link": "nextPage",
"click .previous-page-link": "previousPage"
},
initialize: function(options) {
var view = options.view,
collection = view.collection;
this.view = view;
collection.bind('add', _.bind(this.render, this));
collection.bind('remove', _.bind(this.render, this));
collection.bind('reset', _.bind(this.render, this));
},
render: function() {
var view = this.view,
collection = view.collection,
currentPage = collection.currentPage,
lastPage = collection.totalPages - 1,
messageHtml = this.messageHtml();
this.$el.html(_.template(paging_header_template, {
messageHtml: messageHtml
}));
this.$(".previous-page-link").toggleClass("is-disabled", currentPage === 0).attr('aria-disabled', currentPage === 0);
this.$(".next-page-link").toggleClass("is-disabled", currentPage === lastPage).attr('aria-disabled', currentPage === lastPage);
return this;
},
messageHtml: function() {
var message = '';
var asset_type = false;
if (this.view.collection.assetType) {
if (this.view.collection.sortDirection === 'asc') {
// Translators: sample result:
// "Showing 0-9 out of 25 total, filtered by Images, sorted by Date Added ascending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, filtered by %(asset_type)s, sorted by %(sort_name)s ascending');
} else {
// Translators: sample result:
// "Showing 0-9 out of 25 total, filtered by Images, sorted by Date Added descending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, filtered by %(asset_type)s, sorted by %(sort_name)s descending');
}
asset_type = this.filterNameLabel();
}
else {
if (this.view.collection.sortDirection === 'asc') {
// Translators: sample result:
// "Showing 0-9 out of 25 total, sorted by Date Added ascending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, sorted by %(sort_name)s ascending');
} else {
// Translators: sample result:
// "Showing 0-9 out of 25 total, sorted by Date Added descending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, sorted by %(sort_name)s descending');
}
}
return '<p>' + interpolate(message, {
current_item_range: this.currentItemRangeLabel(),
total_items_count: this.totalItemsCountLabel(),
asset_type: asset_type,
sort_name: this.sortNameLabel()
}, true) + "</p>";
},
currentItemRangeLabel: function() {
var view = this.view,
collection = view.collection,
start = collection.start,
count = collection.size(),
end = start + count;
return interpolate('<span class="count-current-shown">%(start)s-%(end)s</span>', {
start: Math.min(start + 1, end),
end: end
}, true);
},
totalItemsCountLabel: function() {
var totalItemsLabel;
// Translators: turns into "25 total" to be used in other sentences, e.g. "Showing 0-9 out of 25 total".
totalItemsLabel = interpolate(gettext('%(total_items)s total'), {
total_items: this.view.collection.totalCount
}, true);
return interpolate('<span class="count-total">%(total_items_label)s</span>', {
total_items_label: totalItemsLabel
}, true);
},
sortNameLabel: function() {
return interpolate('<span class="sort-order">%(sort_name)s</span>', {
sort_name: this.view.sortDisplayName()
}, true);
},
filterNameLabel: function() {
return interpolate('<span class="filter-column">%(filter_name)s</span>', {
filter_name: this.view.filterDisplayName()
}, true);
},
nextPage: function() {
this.view.nextPage();
},
previousPage: function() {
this.view.previousPage();
}
});
return PagingHeader;
}); // end define();
...@@ -64,7 +64,6 @@ lib_paths: ...@@ -64,7 +64,6 @@ lib_paths:
- xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js - xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js
- xmodule_js/common_static/js/xblock/ - xmodule_js/common_static/js/xblock/
- xmodule_js/common_static/coffee/src/xblock/ - xmodule_js/common_static/coffee/src/xblock/
- xmodule_js/common_static/js/vendor/URI.min.js
- xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport.js - xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport.js
- xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload.js - xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload.js
- xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process.js - xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process.js
......
<div class="meta-wrap">
<div class="meta">
<%= messageHtml %>
</div>
<nav class="pagination pagination-compact top" aria-label="Compact Pagination">
<ol>
<li class="nav-item previous"><a class="nav-link previous-page-link" href="#"><i class="icon fa fa-angle-left"></i> <span class="nav-label"><%- gettext("Previous") %></span></a></li>
<li class="nav-item next"><a class="nav-link next-page-link" href="#"><span class="nav-label"><%- gettext("Next") %></span> <i class="icon fa fa-angle-right"></i></a></li>
</ol>
</nav>
</div>
/**
* A generic paging collection for use with a ListView and PagingFooter.
*
* By default this collection is designed to work with Django Rest Framework APIs, but can be configured to work with
* others. There is support for ascending or descending sort on a particular field, as well as filtering on a field.
* While the backend API may use either zero or one indexed page numbers, this collection uniformly exposes a one
* indexed interface to make consumption easier for views.
*
* Subclasses may want to override the following properties:
* - url (string): The base url for the API endpoint.
* - isZeroIndexed (boolean): If true, API calls will use page numbers starting at zero. Defaults to false.
* - perPage (number): Count of elements to fetch for each page.
* - server_api (object): Query parameters for the API call. Subclasses may add entries as necessary. By default,
* a 'sort_order' field is included to specify the field to sort on. This field may be removed for subclasses
* that do not support sort ordering, or support it in a non-standard way. By default filterField and
* sortDirection do not affect the API calls. It is up to subclasses to add this information to the appropriate
* query string parameters in server_api.
*/
;(function (define) {
'use strict';
define(['backbone.paginator'], function (BackbonePaginator) {
var PagingCollection = BackbonePaginator.requestPager.extend({
initialize: function () {
// These must be initialized in the constructor because otherwise all PagingCollections would point
// to the same object references for sortableFields and filterableFields.
this.sortableFields = {};
this.filterableFields = {};
},
isZeroIndexed: false,
perPage: 10,
sortField: '',
sortDirection: 'descending',
sortableFields: {},
filterField: '',
filterableFields: {},
paginator_core: {
type: 'GET',
dataType: 'json',
url: function () { return this.url; }
},
paginator_ui: {
firstPage: function () { return this.isZeroIndexed ? 0 : 1; },
// Specifies the initial page during collection initialization
currentPage: function () { return this.isZeroIndexed ? 0 : 1; },
perPage: function () { return this.perPage; }
},
server_api: {
'page': function () { return this.currentPage; },
'page_size': function () { return this.perPage; },
'sort_order': function () { return this.sortField; }
},
parse: function (response) {
this.totalCount = response.count;
this.currentPage = response.current_page;
this.totalPages = response.num_pages;
this.start = response.start;
this.sortField = response.sort_order;
return response.results;
},
/**
* Returns the current page number as if numbering starts on page one, regardless of the indexing of the
* underlying server API.
*/
getPage: function () {
return this.currentPage + (this.isZeroIndexed ? 1 : 0);
},
/**
* Sets the current page of the collection. Page is assumed to be one indexed, regardless of the indexing
* of the underlying server API. If there is an error fetching the page, the Backbone 'error' event is
* triggered and the page does not change. A 'page_changed' event is triggered on a successful page change.
* @param page one-indexed page to change to
*/
setPage: function (page) {
var oldPage = this.currentPage,
self = this;
this.goTo(page - (this.isZeroIndexed ? 1 : 0), {reset: true}).then(
function () {
self.trigger('page_changed');
},
function () {
self.currentPage = oldPage;
}
);
},
/**
* Returns true if the collection has a next page, false otherwise.
*/
hasNextPage: function () {
return this.getPage() < this.totalPages;
},
/**
* Returns true if the collection has a previous page, false otherwise.
*/
hasPreviousPage: function () {
return this.getPage() > 1;
},
/**
* Moves the collection to the next page if it exists.
*/
nextPage: function () {
if (this.hasNextPage()) {
this.setPage(this.getPage() + 1);
}
},
/**
* Moves the collection to the previous page if it exists.
*/
previousPage: function () {
if (this.hasPreviousPage()) {
this.setPage(this.getPage() - 1);
}
},
/**
* Adds the given field to the list of fields that can be sorted on.
* @param fieldName name of the field for the server API
* @param displayName name of the field to display to the user
*/
registerSortableField: function (fieldName, displayName) {
this.addField(this.sortableFields, fieldName, displayName);
},
/**
* Adds the given field to the list of fields that can be filtered on.
* @param fieldName name of the field for the server API
* @param displayName name of the field to display to the user
*/
registerFilterableField: function (fieldName, displayName) {
this.addField(this.filterableFields, fieldName, displayName);
},
/**
* For internal use only. Adds the given field to the given collection of fields.
* @param fields object of existing fields
* @param fieldName name of the field for the server API
* @param displayName name of the field to display to the user
*/
addField: function (fields, fieldName, displayName) {
fields[fieldName] = {
displayName: displayName
};
},
/**
* Returns the display name of the field that the collection is currently sorted on.
*/
sortDisplayName: function () {
return this.sortableFields[this.sortField].displayName;
},
/**
* Returns the display name of the field that the collection is currently filtered on.
*/
filterDisplayName: function () {
return this.filterableFields[this.filterField].displayName;
},
/**
* Sets the field to sort on. Sends a request to the server to fetch the first page of the collection with
* the new sort order. If successful, the collection resets to page one with the new data.
* @param fieldName name of the field to sort on
* @param toggleDirection if true, the sort direction is toggled if the given field was already set
*/
setSortField: function (fieldName, toggleDirection) {
if (toggleDirection) {
if (this.sortField === fieldName) {
this.sortDirection = PagingCollection.SortDirection.flip(this.sortDirection);
} else {
this.sortDirection = PagingCollection.SortDirection.DESCENDING;
}
}
this.sortField = fieldName;
this.setPage(1);
},
/**
* Sets the direction of the sort. Sends a request to the server to fetch the first page of the collection
* with the new sort order. If successful, the collection resets to page one with the new data.
* @param direction either ASCENDING or DESCENDING from PagingCollection.SortDirection.
*/
setSortDirection: function (direction) {
this.sortDirection = direction;
this.setPage(1);
},
/**
* Sets the field to filter on. Sends a request to the server to fetch the first page of the collection
* with the new filter options. If successful, the collection resets to page one with the new data.
* @param fieldName name of the field to filter on
*/
setFilterField: function (fieldName) {
this.filterField = fieldName;
this.setPage(1);
}
}, {
SortDirection: {
ASCENDING: 'ascending',
DESCENDING: 'descending',
flip: function (direction) {
return direction === this.ASCENDING ? this.DESCENDING : this.ASCENDING;
}
}
});
return PagingCollection;
});
}).call(this, define || RequireJS.define);
/**
* Generic view to render a collection.
*/
;(function (define) {
'use strict';
define(['backbone', 'underscore'], function(Backbone, _) {
var ListView = Backbone.View.extend({
/**
* Override with the view used to render models in the collection.
*/
itemViewClass: Backbone.View,
initialize: function (options) {
this.itemViewClass = options.itemViewClass || this.itemViewClass;
// TODO: at some point we will want 'add' and 'remove'
// not to re-render the whole collection, but this is
// not currently required.
this.collection.on('add', this.render, this);
this.collection.on('remove', this.render, this);
this.collection.on('reset', this.render, this);
this.collection.on('sync', this.render, this);
this.collection.on('sort', this.render, this);
// Keep track of our children for garbage collection
this.itemViews = [];
},
render: function () {
// Remove old children views
_.each(this.itemViews, function (childView) {
childView.remove();
});
this.itemViews = [];
// Render the collection
this.collection.each(function (model) {
var itemView = new this.itemViewClass({model: model});
this.$el.append(itemView.render().el);
this.itemViews.push(itemView);
}, this);
return this;
}
});
return ListView;
});
}).call(this, define || RequireJS.define);
define(["underscore", "backbone", "text!common/templates/components/paging-footer.underscore"], ;(function (define) {
function(_, Backbone, paging_footer_template) { 'use strict';
define(["underscore", "gettext", "backbone", "text!common/templates/components/paging-footer.underscore"],
function(_, gettext, Backbone, paging_footer_template) {
var PagingFooter = Backbone.View.extend({ var PagingFooter = Backbone.View.extend({
events : { events : {
"click .next-page-link": "nextPage", "click .next-page-link": "nextPage",
"click .previous-page-link": "previousPage", "click .previous-page-link": "previousPage",
"change .page-number-input": "changePage" "change .page-number-input": "changePage"
}, },
initialize: function(options) { initialize: function(options) {
var view = options.view, this.collection = options.collection;
collection = view.collection; this.hideWhenOnePage = options.hideWhenOnePage || false;
this.view = view; this.collection.bind('add', _.bind(this.render, this));
collection.bind('add', _.bind(this.render, this)); this.collection.bind('remove', _.bind(this.render, this));
collection.bind('remove', _.bind(this.render, this)); this.collection.bind('reset', _.bind(this.render, this));
collection.bind('reset', _.bind(this.render, this)); this.render();
this.render(); },
},
render: function() { render: function() {
var view = this.view, var onFirstPage = !this.collection.hasPreviousPage(),
collection = view.collection, onLastPage = !this.collection.hasNextPage();
currentPage = collection.currentPage, if (this.hideWhenOnePage) {
lastPage = collection.totalPages - 1; if (this.collection.totalPages <= 1) {
this.$el.html(_.template(paging_footer_template, { this.$el.addClass('hidden');
current_page: collection.currentPage, } else if (this.$el.hasClass('hidden')) {
total_pages: collection.totalPages this.$el.removeClass('hidden');
})); }
this.$(".previous-page-link").toggleClass("is-disabled", currentPage === 0).attr('aria-disabled', currentPage === 0);; }
this.$(".next-page-link").toggleClass("is-disabled", currentPage === lastPage).attr('aria-disabled', currentPage === lastPage); this.$el.html(_.template(paging_footer_template, {
return this; current_page: this.collection.getPage(),
}, total_pages: this.collection.totalPages
}));
this.$(".previous-page-link").toggleClass("is-disabled", onFirstPage).attr('aria-disabled', onFirstPage);
this.$(".next-page-link").toggleClass("is-disabled", onLastPage).attr('aria-disabled', onLastPage);
return this;
},
changePage: function() { changePage: function() {
var view = this.view, var collection = this.collection,
collection = view.collection, currentPage = collection.getPage(),
currentPage = collection.currentPage + 1, pageInput = this.$("#page-number-input"),
pageInput = this.$("#page-number-input"), pageNumber = parseInt(pageInput.val(), 10),
pageNumber = parseInt(pageInput.val(), 10); validInput = true;
if (pageNumber > collection.totalPages) { if (!pageNumber || pageNumber > collection.totalPages || pageNumber < 1) {
pageNumber = false; validInput = false;
} }
if (pageNumber <= 0) { // If we still have a page number by this point,
pageNumber = false; // and it's not the current page, load it.
} if (validInput && pageNumber !== currentPage) {
// If we still have a page number by this point, collection.setPage(pageNumber);
// and it's not the current page, load it. }
if (pageNumber && pageNumber !== currentPage) { pageInput.val(''); // Clear the value as the label will show beneath it
view.setPage(pageNumber - 1); },
}
pageInput.val(""); // Clear the value as the label will show beneath it
},
nextPage: function() { nextPage: function() {
this.view.nextPage(); this.collection.nextPage();
}, },
previousPage: function() { previousPage: function() {
this.view.previousPage(); this.collection.previousPage();
} }
}); });
return PagingFooter; return PagingFooter;
}); // end define(); }); // end define();
}).call(this, define || RequireJS.define);
define(["underscore", "backbone", "gettext", "text!common/templates/components/paging-header.underscore"], ;(function (define) {
function(_, Backbone, gettext, paging_header_template) { 'use strict';
define([
'backbone',
'underscore',
'gettext',
'text!common/templates/components/paging-header.underscore'
], function (Backbone, _, gettext, headerTemplate) {
var PagingHeader = Backbone.View.extend({ var PagingHeader = Backbone.View.extend({
events : { initialize: function (options) {
"click .next-page-link": "nextPage", this.collections = options.collection;
"click .previous-page-link": "previousPage" this.collection.bind('add', _.bind(this.render, this));
}, this.collection.bind('remove', _.bind(this.render, this));
this.collection.bind('reset', _.bind(this.render, this));
initialize: function(options) { },
var view = options.view,
collection = view.collection; render: function () {
this.view = view; var message,
collection.bind('add', _.bind(this.render, this)); start = this.collection.start,
collection.bind('remove', _.bind(this.render, this)); end = start + this.collection.length,
collection.bind('reset', _.bind(this.render, this)); num_items = this.collection.totalCount,
}, context = {first_index: Math.min(start + 1, end), last_index: end, num_items: num_items};
if (end <= 1) {
render: function() { message = interpolate(gettext('Showing %(first_index)s out of %(num_items)s total'), context, true);
var view = this.view, } else {
collection = view.collection, message = interpolate(
currentPage = collection.currentPage, gettext('Showing %(first_index)s-%(last_index)s out of %(num_items)s total'),
lastPage = collection.totalPages - 1, context, true
messageHtml = this.messageHtml(); );
this.$el.html(_.template(paging_header_template, {
messageHtml: messageHtml
}));
this.$(".previous-page-link").toggleClass("is-disabled", currentPage === 0).attr('aria-disabled', currentPage === 0);
this.$(".next-page-link").toggleClass("is-disabled", currentPage === lastPage).attr('aria-disabled', currentPage === lastPage);
return this;
},
messageHtml: function() {
var message = '';
var asset_type = false;
if (this.view.collection.assetType) {
if (this.view.collection.sortDirection === 'asc') {
// Translators: sample result:
// "Showing 0-9 out of 25 total, filtered by Images, sorted by Date Added ascending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, filtered by %(asset_type)s, sorted by %(sort_name)s ascending');
} else {
// Translators: sample result:
// "Showing 0-9 out of 25 total, filtered by Images, sorted by Date Added descending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, filtered by %(asset_type)s, sorted by %(sort_name)s descending');
}
asset_type = this.filterNameLabel();
}
else {
if (this.view.collection.sortDirection === 'asc') {
// Translators: sample result:
// "Showing 0-9 out of 25 total, sorted by Date Added ascending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, sorted by %(sort_name)s ascending');
} else {
// Translators: sample result:
// "Showing 0-9 out of 25 total, sorted by Date Added descending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, sorted by %(sort_name)s descending');
}
} }
this.$el.html(_.template(headerTemplate, {message: message}));
return '<p>' + interpolate(message, { return this;
current_item_range: this.currentItemRangeLabel(),
total_items_count: this.totalItemsCountLabel(),
asset_type: asset_type,
sort_name: this.sortNameLabel()
}, true) + "</p>";
},
currentItemRangeLabel: function() {
var view = this.view,
collection = view.collection,
start = collection.start,
count = collection.size(),
end = start + count;
return interpolate('<span class="count-current-shown">%(start)s-%(end)s</span>', {
start: Math.min(start + 1, end),
end: end
}, true);
},
totalItemsCountLabel: function() {
var totalItemsLabel;
// Translators: turns into "25 total" to be used in other sentences, e.g. "Showing 0-9 out of 25 total".
totalItemsLabel = interpolate(gettext('%(total_items)s total'), {
total_items: this.view.collection.totalCount
}, true);
return interpolate('<span class="count-total">%(total_items_label)s</span>', {
total_items_label: totalItemsLabel
}, true);
},
sortNameLabel: function() {
return interpolate('<span class="sort-order">%(sort_name)s</span>', {
sort_name: this.view.sortDisplayName()
}, true);
},
filterNameLabel: function() {
return interpolate('<span class="filter-column">%(filter_name)s</span>', {
filter_name: this.view.filterDisplayName()
}, true);
},
nextPage: function() {
this.view.nextPage();
},
previousPage: function() {
this.view.previousPage();
} }
}); });
return PagingHeader; return PagingHeader;
}); // end define(); });
}).call(this, define || RequireJS.define);
define([],
function () {
var PagedMixin = {
setPage: function (page) {
var self = this,
collection = self.collection,
oldPage = collection.currentPage;
collection.goTo(page, {
reset: true,
success: function () {
window.scrollTo(0, 0);
},
error: function (collection) {
collection.currentPage = oldPage;
self.onError();
}
});
},
nextPage: function() {
var collection = this.collection,
currentPage = collection.currentPage,
lastPage = collection.totalPages - 1;
if (currentPage < lastPage) {
this.setPage(currentPage + 1);
}
},
previousPage: function() {
var collection = this.collection,
currentPage = collection.currentPage;
if (currentPage > 0) {
this.setPage(currentPage - 1);
}
}
};
return PagedMixin;
});
define(['jquery', 'backbone', 'underscore', 'common/js/components/views/list'],
function ($, Backbone, _, ListView) {
'use strict';
describe('ListView', function () {
var Model = Backbone.Model.extend({
defaults: {
name: 'default name'
}
}),
View = Backbone.View.extend({
tagName: 'div',
className: 'my-view',
template: _.template('<p>Name: "<%- name %>"</p>'),
render: function () {
this.$el.html(this.template(this.model.attributes));
return this;
}
}),
Collection = Backbone.Collection.extend({
model: Model
}),
expectListNames = function (names) {
expect(listView.$('.my-view').length).toBe(names.length);
_.each(names, function (name, index) {
expect($(listView.$('.my-view')[index]).text()).toContain(name);
});
},
listView;
beforeEach(function () {
setFixtures('<div class="list"></div>');
listView = new ListView({
el: $('.list'),
collection: new Collection(
[{name: 'first model'}, {name: 'second model'}, {name: 'third model'}]
),
itemViewClass: View
});
listView.render();
});
it('renders itself', function () {
expectListNames(['first model', 'second model', 'third model']);
});
it('does not render subviews for an empty collection', function () {
listView.collection.set([]);
expectListNames([]);
});
it('re-renders itself when the collection changes', function () {
expectListNames(['first model', 'second model', 'third model']);
listView.collection.set([{name: 'foo'}, {name: 'bar'}, {name: 'third model'}]);
expectListNames(['foo', 'bar', 'third model']);
listView.collection.reset([{name: 'baz'}, {name: 'bar'}, {name: 'quux'}]);
expectListNames(['baz', 'bar', 'quux']);
});
it('re-renders itself when items are added to the collection', function () {
expectListNames(['first model', 'second model', 'third model']);
listView.collection.add({name: 'fourth model'});
expectListNames(['first model', 'second model', 'third model', 'fourth model']);
listView.collection.add({name: 'zeroth model'}, {at: 0});
expectListNames(['zeroth model', 'first model', 'second model', 'third model', 'fourth model']);
listView.collection.add({name: 'second-and-a-half model'}, {at: 3});
expectListNames([
'zeroth model', 'first model', 'second model',
'second-and-a-half model', 'third model', 'fourth model'
]);
});
it('re-renders itself when items are removed from the collection', function () {
listView.collection.reset([{name: 'one'}, {name: 'two'}, {name: 'three'}, {name: 'four'}]);
expectListNames(['one', 'two', 'three', 'four']);
listView.collection.remove(listView.collection.at(0));
expectListNames(['two', 'three', 'four']);
listView.collection.remove(listView.collection.at(1));
expectListNames(['two', 'four']);
listView.collection.remove(listView.collection.at(1));
expectListNames(['two']);
listView.collection.remove(listView.collection.at(0));
expectListNames([]);
});
it('removes old views', function () {
listView.collection.reset(null);
expect(listView.itemViews).toEqual([]);
});
});
});
define(["backbone.paginator", "backbone"], function(BackbonePaginator, Backbone) {
// This code was adapted from collections/asset.js.
var PagingCollection = BackbonePaginator.requestPager.extend({
model : Backbone.Model,
paginator_core: {
type: 'GET',
accepts: 'application/json',
dataType: 'json',
url: function() { return this.url; }
},
paginator_ui: {
firstPage: 0,
currentPage: 0,
perPage: 50
},
server_api: {
'page': function() { return this.currentPage; },
'page_size': function() { return this.perPage; },
'sort': function() { return this.sortField; },
'direction': function() { return this.sortDirection; },
'format': 'json'
},
parse: function(response) {
var totalCount = response.totalCount,
start = response.start,
currentPage = response.page,
pageSize = response.pageSize,
totalPages = Math.ceil(totalCount / pageSize);
this.totalCount = totalCount;
this.totalPages = Math.max(totalPages, 1); // Treat an empty collection as having 1 page...
this.currentPage = currentPage;
this.start = start;
return response.items;
}
});
return PagingCollection;
});
define([
'URI',
'underscore',
'common/js/spec_helpers/ajax_helpers',
'common/js/components/views/paging_footer',
'common/js/components/collections/paging_collection'
], function (URI, _, AjaxHelpers, PagingFooter, PagingCollection) {
'use strict';
describe("PagingFooter", function () {
var pagingFooter,
mockPage = function (currentPage, numPages, collectionLength) {
if (_.isUndefined(collectionLength)) {
collectionLength = 1;
}
return {
count: null,
current_page: currentPage,
num_pages: numPages,
start: null,
results: _.map(_.range(collectionLength), function() { return {}; }) // need to have non-empty collection to render
};
},
nextPageCss = '.next-page-link',
previousPageCss = '.previous-page-link',
currentPageCss = '.current-page',
totalPagesCss = '.total-pages',
pageNumberInputCss = '.page-number-input';
beforeEach(function () {
setFixtures('<div class="paging-footer"></div>');
pagingFooter = new PagingFooter({
el: $('.paging-footer'),
collection: new PagingCollection(mockPage(1, 2), {parse: true})
}).render();
});
describe('when hideWhenOnePage is true', function () {
beforeEach(function () {
pagingFooter.hideWhenOnePage = true;
});
it('should not render itself for an empty collection', function () {
pagingFooter.collection.reset(mockPage(0, 0, 0), {parse: true});
expect(pagingFooter.$el).toHaveClass('hidden');
});
it('should not render itself for a dataset with just one page', function () {
pagingFooter.collection.reset(mockPage(1, 1), {parse: true});
expect(pagingFooter.$el).toHaveClass('hidden');
});
});
describe('when hideWhenOnepage is false', function () {
it('should render itself for an empty collection', function () {
pagingFooter.collection.reset(mockPage(0, 0, 0), {parse: true});
expect(pagingFooter.$el).not.toHaveClass('hidden');
});
it('should render itself for a dataset with just one page', function () {
pagingFooter.collection.reset(mockPage(1, 1), {parse: true});
expect(pagingFooter.$el).not.toHaveClass('hidden');
});
});
describe("Next page button", function () {
it('does not move forward if a server error occurs', function () {
var requests = AjaxHelpers.requests(this);
pagingFooter.$(nextPageCss).click();
requests[0].respond(500);
expect(pagingFooter.$(currentPageCss)).toHaveText('1');
});
it('can move to the next page', function () {
var requests = AjaxHelpers.requests(this);
pagingFooter.$(nextPageCss).click();
AjaxHelpers.respondWithJson(requests, mockPage(2, 2));
expect(pagingFooter.collection.currentPage).toBe(2);
});
it('should be enabled when there is at least one more page', function () {
// in beforeEach we're set up on page 1 out of 2
expect(pagingFooter.$(nextPageCss)).not.toHaveClass('is-disabled');
});
it('should be disabled on the final page', function () {
var requests = AjaxHelpers.requests(this);
pagingFooter.$(nextPageCss).click();
AjaxHelpers.respondWithJson(requests, mockPage(2, 2));
expect(pagingFooter.$(nextPageCss)).toHaveClass('is-disabled');
});
});
describe("Previous page button", function () {
it('does not move back if a server error occurs', function () {
var requests = AjaxHelpers.requests(this);
pagingFooter.collection.reset(mockPage(2, 2), {parse: true});
pagingFooter.$(previousPageCss).click();
requests[0].respond(500);
expect(pagingFooter.$(currentPageCss)).toHaveText('2');
});
it('can go back a page', function () {
var requests = AjaxHelpers.requests(this);
pagingFooter.$(nextPageCss).click();
AjaxHelpers.respondWithJson(requests, mockPage(2, 2));
pagingFooter.$(previousPageCss).click();
AjaxHelpers.respondWithJson(requests, mockPage(1, 2));
expect(pagingFooter.$(currentPageCss)).toHaveText('1');
});
it('should be disabled on the first page', function () {
expect(pagingFooter.$(previousPageCss)).toHaveClass('is-disabled');
});
it('should be enabled on the second page', function () {
var requests = AjaxHelpers.requests(this);
pagingFooter.$(nextPageCss).click();
AjaxHelpers.respondWithJson(requests, mockPage(2, 2));
expect(pagingFooter.$(previousPageCss)).not.toHaveClass('is-disabled');
});
});
describe("Current page label", function () {
it('should show 1 on the first page', function () {
expect(pagingFooter.$(currentPageCss)).toHaveText('1');
});
it('should show 2 on the second page', function () {
var requests = AjaxHelpers.requests(this);
pagingFooter.$(nextPageCss).click();
AjaxHelpers.respondWithJson(requests, mockPage(2, 2));
expect(pagingFooter.$(currentPageCss)).toHaveText('2');
});
});
describe("Page total label", function () {
it('should show the correct value with more than one page', function () {
expect(pagingFooter.$(totalPagesCss)).toHaveText('2');
});
});
describe("Page input field", function () {
beforeEach(function () {
pagingFooter.render();
});
it('should initially have a blank page input', function () {
expect(pagingFooter.$(pageNumberInputCss)).toHaveValue('');
});
it('should handle invalid page requests', function () {
var requests = AjaxHelpers.requests(this);
pagingFooter.$(pageNumberInputCss).val('abc');
pagingFooter.$(pageNumberInputCss).trigger('change');
expect(pagingFooter.$(currentPageCss)).toHaveText('1');
expect(pagingFooter.$(pageNumberInputCss)).toHaveValue('');
});
it('should switch pages via the input field', function () {
var requests = AjaxHelpers.requests(this);
pagingFooter.$(pageNumberInputCss).val('2');
pagingFooter.$(pageNumberInputCss).trigger('change');
AjaxHelpers.respondWithJson(requests, mockPage(2, 2));
expect(pagingFooter.$(currentPageCss)).toHaveText('2');
expect(pagingFooter.$(pageNumberInputCss)).toHaveValue('');
});
it('should handle AJAX failures when switching pages via the input field', function () {
var requests = AjaxHelpers.requests(this);
pagingFooter.$(pageNumberInputCss).val('2');
pagingFooter.$(pageNumberInputCss).trigger('change');
requests[0].respond(500);
expect(pagingFooter.$(currentPageCss)).toHaveText('1');
expect(pagingFooter.$(pageNumberInputCss)).toHaveValue('');
});
});
});
});
define([
'common/js/components/views/paging_header',
'common/js/components/collections/paging_collection'
], function (PagingHeader, PagingCollection) {
'use strict';
describe('PagingHeader', function () {
var pagingHeader,
newCollection = function (size, perPage) {
var pageSize = 5,
results = _.map(_.range(size), function () { return {}; });
var collection = new PagingCollection(
{
count: results.length,
num_pages: results.length / pageSize,
current_page: 1,
start: 0,
results: _.first(results, perPage)
},
{parse: true}
);
collection.start = 0;
collection.totalCount = results.length;
return collection;
};
it('correctly displays which items are being viewed', function () {
pagingHeader = new PagingHeader({
collection: newCollection(20, 5)
}).render();
expect(pagingHeader.$el.find('.search-count').text())
.toContain('Showing 1-5 out of 20 total');
});
it('reports that all items are on the current page', function () {
pagingHeader = new PagingHeader({
collection: newCollection(5, 5)
}).render();
expect(pagingHeader.$el.find('.search-count').text())
.toContain('Showing 1-5 out of 5 total');
});
it('reports that the page contains a single item', function () {
pagingHeader = new PagingHeader({
collection: newCollection(1, 1)
}).render();
expect(pagingHeader.$el.find('.search-count').text())
.toContain('Showing 1 out of 1 total');
});
});
});
/**
* Generally useful helper functions for writing Jasmine unit tests.
*/
define([], function () {
'use strict';
/**
* Runs func as a test case multiple times, using entries from data as arguments. Like Python's DDT.
* @param data An object mapping test names to arrays of function parameters. The name is passed to it() as the name
* of the test case, and the list of arguments is applied as arguments to func.
* @param func The function that actually expresses the logic of the test.
*/
var withData = function (data, func) {
for (var name in data) {
if (data.hasOwnProperty(name)) {
it(name, function () {
func.apply(this, data[name]);
});
}
}
};
/**
* Runs test multiple times, wrapping each call in a describe with beforeEach specified by setup and arguments and
* name coming from entries in config.
* @param config An object mapping configuration names to arrays of setup function parameters. The name is passed
* to describe as the name of the group of tests, and the list of arguments is applied as arguments to setup.
* @param setup The function to setup the given configuration before each test case. Runs in beforeEach.
* @param test The function that actually express the logic of the test. May include it() or more describe().
*/
var withConfiguration = function (config, setup, test) {
for (var name in config) {
if (config.hasOwnProperty(name)) {
describe(name, function () {
beforeEach(function () {
setup.apply(this, config[name]);
});
test();
});
}
}
};
return {
withData: withData,
withConfiguration: withConfiguration
};
});
<nav class="pagination pagination-full bottom"> <nav class="pagination pagination-full bottom" aria-label="Teams Pagination">
<ol> <div class="nav-item previous"><button class="nav-link previous-page-link"><i class="icon fa fa-angle-left" aria-hidden="true"></i> <span class="nav-label"><%= gettext("Previous") %></span></button></div>
<li class="nav-item previous"><a class="nav-link previous-page-link" href="#"><i class="icon fa fa-angle-left"></i> <span class="nav-label"><%= gettext("Previous") %></span></a></li> <div class="nav-item page">
<li class="nav-item page">
<div class="pagination-form"> <div class="pagination-form">
<label class="page-number-label" for="page-number-input"><%= gettext("Page number") %></label> <label class="page-number-label" for="page-number-input"><%= gettext("Page number") %></label>
<input id="page-number-input" class="page-number-input" name="page-number" type="text" size="4" /> <input id="page-number-input" class="page-number-input" name="page-number" type="text" size="4" autocomplete="off" />
</div> </div>
<span class="current-page"><%= current_page + 1 %></span> <span class="current-page"><%= current_page %></span>
<span class="page-divider">/</span> <span class="sr">&nbsp;out of&nbsp;</span>
<span class="page-divider" aria-hidden="true">/</span>
<span class="total-pages"><%= total_pages %></span> <span class="total-pages"><%= total_pages %></span>
</li> </div>
<li class="nav-item next"><a class="nav-link next-page-link" href="#"><span class="nav-label"><%= gettext("Next") %></span> <i class="icon fa fa-angle-right"></i></a></li> <div class="nav-item next"><button class="nav-link next-page-link"><span class="nav-label"><%= gettext("Next") %></span> <i class="icon fa fa-angle-right" aria-hidden="true"></i></button></div>
</ol>
</nav> </nav>
<div class="meta-wrap"> <div class="search-tools">
<div class="meta"> <span class="search-count">
<%= messageHtml %> <%= message %>
</div> </span>
<nav class="pagination pagination-compact top">
<ol>
<li class="nav-item previous"><a class="nav-link previous-page-link" href="#"><i class="icon fa fa-angle-left"></i> <span class="nav-label"><%= gettext("Previous") %></span></a></li>
<li class="nav-item next"><a class="nav-link next-page-link" href="#"><span class="nav-label"><%= gettext("Next") %></span> <i class="icon fa fa-angle-right"></i></a></li>
</ol>
</nav>
</div> </div>
...@@ -155,7 +155,10 @@ ...@@ -155,7 +155,10 @@
define([ define([
// Run the common tests that use RequireJS. // Run the common tests that use RequireJS.
'common-requirejs/include/common/js/spec/components/paging_spec.js' 'common-requirejs/include/common/js/spec/components/list_spec.js',
'common-requirejs/include/common/js/spec/components/paging_collection_spec.js',
'common-requirejs/include/common/js/spec/components/paging_header_spec.js',
'common-requirejs/include/common/js/spec/components/paging_footer_spec.js'
]); ]);
}).call(this, requirejs, define); }).call(this, requirejs, define);
...@@ -15,7 +15,7 @@ define(['jquery', ...@@ -15,7 +15,7 @@ define(['jquery',
'name': 'Renewable Energy', 'name': 'Renewable Energy',
'description': 'Explore how changes in <ⓡⓔⓝⓔⓦⓐⓑⓛⓔ> ʎƃɹǝuǝ will affect our lives.', 'description': 'Explore how changes in <ⓡⓔⓝⓔⓦⓐⓑⓛⓔ> ʎƃɹǝuǝ will affect our lives.',
'team_count': 34 'team_count': 34
}), })
}); });
}); });
...@@ -23,8 +23,8 @@ define(['jquery', ...@@ -23,8 +23,8 @@ define(['jquery',
expect(view.$el).toHaveClass('square-card'); expect(view.$el).toHaveClass('square-card');
expect(view.$el.find('.card-title').text()).toContain('Renewable Energy'); expect(view.$el.find('.card-title').text()).toContain('Renewable Energy');
expect(view.$el.find('.card-description').text()).toContain('changes in <ⓡⓔⓝⓔⓦⓐⓑⓛⓔ> ʎƃɹǝuǝ'); expect(view.$el.find('.card-description').text()).toContain('changes in <ⓡⓔⓝⓔⓦⓐⓑⓛⓔ> ʎƃɹǝuǝ');
expect(view.$el.find('.card-meta-details').text()).toContain('34 Teams'); expect(view.$el.find('.card-meta').text()).toContain('34 Teams');
expect(view.$el.find('.action').text()).toContain('View'); expect(view.$el.find('.action .sr').text()).toContain('View Teams in the Renewable Energy Topic');
}); });
it('navigates when action button is clicked', function () { it('navigates when action button is clicked', function () {
......
...@@ -41,7 +41,13 @@ ...@@ -41,7 +41,13 @@
description: function () { return this.model.get('description'); }, description: function () { return this.model.get('description'); },
details: function () { return this.detailViews; }, details: function () { return this.detailViews; },
actionClass: 'action-view', actionClass: 'action-view',
actionContent: _.escape(gettext('View')) + ' <span class="icon fa-arrow-right"></span>' actionContent: function () {
var screenReaderText = _.escape(interpolate(
gettext('View Teams in the %(topic_name)s Topic'),
{ topic_name: this.model.get('name') }, true
));
return '<span class="sr">' + screenReaderText + '</span><i class="icon fa fa-arrow-right" aria-hidden="true"></i>';
}
}); });
return TopicCardView; return TopicCardView;
......
...@@ -531,18 +531,19 @@ class TestListTopicsAPI(TeamAPITestCase): ...@@ -531,18 +531,19 @@ class TestListTopicsAPI(TeamAPITestCase):
self.get_topics_list(400) self.get_topics_list(400)
@ddt.data( @ddt.data(
(None, 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power']), (None, 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power'], 'name'),
('name', 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power']), ('name', 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power'], 'name'),
('no_such_field', 400, []), ('no_such_field', 400, [], None),
) )
@ddt.unpack @ddt.unpack
def test_order_by(self, field, status, names): def test_order_by(self, field, status, names, expected_ordering):
data = {'course_id': self.test_course_1.id} data = {'course_id': self.test_course_1.id}
if field: if field:
data['order_by'] = field data['order_by'] = field
topics = self.get_topics_list(status, data) topics = self.get_topics_list(status, data)
if status == 200: if status == 200:
self.assertEqual(names, [topic['name'] for topic in topics['results']]) self.assertEqual(names, [topic['name'] for topic in topics['results']])
self.assertEqual(topics['sort_order'], expected_ordering)
def test_pagination(self): def test_pagination(self):
response = self.get_topics_list(data={ response = self.get_topics_list(data={
...@@ -556,6 +557,10 @@ class TestListTopicsAPI(TeamAPITestCase): ...@@ -556,6 +557,10 @@ class TestListTopicsAPI(TeamAPITestCase):
self.assertIsNone(response['previous']) self.assertIsNone(response['previous'])
self.assertIsNotNone(response['next']) self.assertIsNotNone(response['next'])
def test_default_ordering(self):
response = self.get_topics_list(data={'course_id': self.test_course_1.id})
self.assertEqual(response['sort_order'], 'name')
@ddt.ddt @ddt.ddt
class TestDetailTopicAPI(TeamAPITestCase): class TestDetailTopicAPI(TeamAPITestCase):
......
"""HTTP endpoints for the Teams API.""" """HTTP endpoints for the Teams API."""
from django.shortcuts import render_to_response from django.shortcuts import render_to_response
from opaque_keys.edx.keys import CourseKey
from courseware.courses import get_course_with_access, has_access from courseware.courses import get_course_with_access, has_access
from django.http import Http404 from django.http import Http404
from django.conf import settings from django.conf import settings
from django.core.paginator import Paginator
from django.views.generic.base import View from django.views.generic.base import View
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.authentication import ( from rest_framework.authentication import (
SessionAuthentication, SessionAuthentication,
...@@ -45,6 +46,10 @@ from .serializers import CourseTeamSerializer, CourseTeamCreationSerializer, Top ...@@ -45,6 +46,10 @@ from .serializers import CourseTeamSerializer, CourseTeamCreationSerializer, Top
from .errors import AlreadyOnTeamInCourse, NotEnrolledInCourseForTeam from .errors import AlreadyOnTeamInCourse, NotEnrolledInCourseForTeam
# Constants
TOPICS_PER_PAGE = 12
class TeamsDashboardView(View): class TeamsDashboardView(View):
""" """
View methods related to the teams dashboard. View methods related to the teams dashboard.
...@@ -67,7 +72,13 @@ class TeamsDashboardView(View): ...@@ -67,7 +72,13 @@ class TeamsDashboardView(View):
not has_access(request.user, 'staff', course, course.id): not has_access(request.user, 'staff', course, course.id):
raise Http404 raise Http404
context = {"course": course} sort_order = 'name'
topics = get_ordered_topics(course, sort_order)
topics_page = Paginator(topics, TOPICS_PER_PAGE).page(1)
topics_serializer = PaginationSerializer(instance=topics_page, context={'sort_order': sort_order})
context = {
"course": course, "topics": topics_serializer.data, "topics_url": reverse('topics_list', request=request)
}
return render_to_response("teams/teams.html", context) return render_to_response("teams/teams.html", context)
...@@ -479,7 +490,7 @@ class TopicListView(GenericAPIView): ...@@ -479,7 +490,7 @@ class TopicListView(GenericAPIView):
authentication_classes = (OAuth2Authentication, SessionAuthentication) authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated,) permission_classes = (permissions.IsAuthenticated,)
paginate_by = 10 paginate_by = TOPICS_PER_PAGE
paginate_by_param = 'page_size' paginate_by_param = 'page_size'
pagination_serializer_class = PaginationSerializer pagination_serializer_class = PaginationSerializer
serializer_class = TopicSerializer serializer_class = TopicSerializer
...@@ -510,11 +521,9 @@ class TopicListView(GenericAPIView): ...@@ -510,11 +521,9 @@ class TopicListView(GenericAPIView):
if not has_team_api_access(request.user, course_id): if not has_team_api_access(request.user, course_id):
return Response(status=status.HTTP_403_FORBIDDEN) return Response(status=status.HTTP_403_FORBIDDEN)
topics = course_module.teams_topics
ordering = request.QUERY_PARAMS.get('order_by', 'name') ordering = request.QUERY_PARAMS.get('order_by', 'name')
if ordering == 'name': if ordering == 'name':
topics = sorted(topics, key=lambda t: t['name'].lower()) topics = get_ordered_topics(course_module, ordering)
else: else:
return Response({ return Response({
'developer_message': "unsupported order_by value {}".format(ordering), 'developer_message': "unsupported order_by value {}".format(ordering),
...@@ -523,9 +532,23 @@ class TopicListView(GenericAPIView): ...@@ -523,9 +532,23 @@ class TopicListView(GenericAPIView):
page = self.paginate_queryset(topics) page = self.paginate_queryset(topics)
serializer = self.get_pagination_serializer(page) serializer = self.get_pagination_serializer(page)
serializer.context = {'sort_order': ordering}
return Response(serializer.data) # pylint: disable=maybe-no-member return Response(serializer.data) # pylint: disable=maybe-no-member
def get_ordered_topics(course_module, ordering):
"""Return a sorted list of team topics.
Arguments:
course_module (xmodule): the course which owns the team topics
ordering (str): the key belonging to topic dicts by which we sort
Returns:
list: a list of sorted team topics
"""
return sorted(course_module.teams_topics, key=lambda t: t[ordering].lower())
class TopicDetailView(APIView): class TopicDetailView(APIView):
""" """
**Use Cases** **Use Cases**
......
...@@ -1634,7 +1634,6 @@ STATICFILES_IGNORE_PATTERNS = ( ...@@ -1634,7 +1634,6 @@ STATICFILES_IGNORE_PATTERNS = (
# Symlinks used by js-test-tool # Symlinks used by js-test-tool
"xmodule_js", "xmodule_js",
"common",
) )
PIPELINE_UGLIFYJS_BINARY = 'node_modules/.bin/uglifyjs' PIPELINE_UGLIFYJS_BINARY = 'node_modules/.bin/uglifyjs'
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
* "square_card" or "list_card". Defaults to "square_card". * "square_card" or "list_card". Defaults to "square_card".
* - action (function): Action to take when the action button is clicked. Defaults to a no-op. * - action (function): Action to take when the action button is clicked. Defaults to a no-op.
* - cardClass (string or function): Class name for this card's DOM element. Defaults to the empty string. * - cardClass (string or function): Class name for this card's DOM element. Defaults to the empty string.
* - pennant (string or function): Text of the card's pennant. No pennant is displayed if this value is falsy.
* - title (string or function): Title of the card. Defaults to the empty string. * - title (string or function): Title of the card. Defaults to the empty string.
* - description (string or function): Description of the card. Defaults to the empty string. * - description (string or function): Description of the card. Defaults to the empty string.
* - details (array or function): Array of child views to be rendered as details of this card. The class "meta-detail" * - details (array or function): Array of child views to be rendered as details of this card. The class "meta-detail"
...@@ -17,10 +18,10 @@ ...@@ -17,10 +18,10 @@
;(function (define) { ;(function (define) {
'use strict'; 'use strict';
define(['jquery', define(['jquery',
'underscore',
'backbone', 'backbone',
'text!templates/components/card/square_card.underscore', 'text!templates/components/card/card.underscore'],
'text!templates/components/card/list_card.underscore'], function ($, _, Backbone, cardTemplate) {
function ($, Backbone, squareCardTemplate, listCardTemplate) {
var CardView = Backbone.View.extend({ var CardView = Backbone.View.extend({
events: { events: {
'click .action' : 'action' 'click .action' : 'action'
...@@ -40,13 +41,11 @@ ...@@ -40,13 +41,11 @@
}, },
initialize: function () { initialize: function () {
this.template = this.switchOnConfiguration(
_.template(squareCardTemplate),
_.template(listCardTemplate)
);
this.render(); this.render();
}, },
template: _.template(cardTemplate),
switchOnConfiguration: function (square_result, list_result) { switchOnConfiguration: function (square_result, list_result) {
return this.callIfFunction(this.configuration) === 'square_card' ? return this.callIfFunction(this.configuration) === 'square_card' ?
square_result : list_result; square_result : list_result;
...@@ -61,20 +60,30 @@ ...@@ -61,20 +60,30 @@
}, },
className: function () { className: function () {
return 'card ' + var result = 'card ' +
this.switchOnConfiguration('square-card', 'list-card') + this.switchOnConfiguration('square-card', 'list-card') + ' ' +
' ' + this.callIfFunction(this.cardClass); this.callIfFunction(this.cardClass);
if (this.callIfFunction(this.pennant)) {
result += ' has-pennant';
}
return result;
}, },
render: function () { render: function () {
var maxLength = 72,
description = this.callIfFunction(this.description);
if (description.length > maxLength) {
description = description.substring(0, maxLength).trim() + '...'
}
this.$el.html(this.template({ this.$el.html(this.template({
pennant: this.callIfFunction(this.pennant),
title: this.callIfFunction(this.title), title: this.callIfFunction(this.title),
description: this.callIfFunction(this.description), description: description,
action_class: this.callIfFunction(this.actionClass), action_class: this.callIfFunction(this.actionClass),
action_url: this.callIfFunction(this.actionUrl), action_url: this.callIfFunction(this.actionUrl),
action_content: this.callIfFunction(this.actionContent) action_content: this.callIfFunction(this.actionContent)
})); }));
var detailsEl = this.$el.find('.card-meta-details'); var detailsEl = this.$el.find('.card-meta');
_.each(this.callIfFunction(this.details), function (detail) { _.each(this.callIfFunction(this.details), function (detail) {
// Call setElement to rebind event handlers // Call setElement to rebind event handlers
detail.setElement(detail.el).render(); detail.setElement(detail.el).render();
...@@ -86,6 +95,7 @@ ...@@ -86,6 +95,7 @@
action: function () { }, action: function () { },
cardClass: '', cardClass: '',
pennant: '',
title: '', title: '',
description: '', description: '',
details: [], details: [],
......
...@@ -12,13 +12,20 @@ ...@@ -12,13 +12,20 @@
it('can render itself as a square card', function () { it('can render itself as a square card', function () {
var view = new CardView({ configuration: 'square_card' }); var view = new CardView({ configuration: 'square_card' });
expect(view.$el).toHaveClass('square-card'); expect(view.$el).toHaveClass('square-card');
expect(view.$el.find('.card-meta-wrapper .action').length).toBe(1); expect(view.$el.find('.wrapper-card-meta .action').length).toBe(1);
}); });
it('can render itself as a list card', function () { it('can render itself as a list card', function () {
var view = new CardView({ configuration: 'list_card' }); var view = new CardView({ configuration: 'list_card' });
expect(view.$el).toHaveClass('list-card'); expect(view.$el).toHaveClass('list-card');
expect(view.$el.find('.card-core-wrapper .action').length).toBe(1); expect(view.$el.find('.wrapper-card-meta .action').length).toBe(1);
});
it('renders a pennant only if the pennant value is truthy', function () {
var view = new (CardView.extend({ pennant: '' }))();
expect(view.$el.find('.card-type').length).toBe(0);
view = new (CardView.extend({ pennant: 'Test Pennant' }))();
expect(view.$el.find('.card-type').length).toBe(1);
}); });
it('can render child views', function () { it('can render child views', function () {
...@@ -38,6 +45,7 @@ ...@@ -38,6 +45,7 @@
var verifyContent = function (view) { var verifyContent = function (view) {
expect(view.$el).toHaveClass('test-card'); expect(view.$el).toHaveClass('test-card');
expect(view.$el.find('.card-type').text()).toContain('Pennant');
expect(view.$el.find('.card-title').text()).toContain('A test title'); expect(view.$el.find('.card-title').text()).toContain('A test title');
expect(view.$el.find('.card-description').text()).toContain('A test description'); expect(view.$el.find('.card-description').text()).toContain('A test description');
expect(view.$el.find('.action')).toHaveClass('test-action'); expect(view.$el.find('.action')).toHaveClass('test-action');
...@@ -45,9 +53,10 @@ ...@@ -45,9 +53,10 @@
expect(view.$el.find('.action').text()).toContain('A test action'); expect(view.$el.find('.action').text()).toContain('A test action');
}; };
it('can have strings for cardClass, title, description, and action', function () { it('can have strings for cardClass, pennant, title, description, and action', function () {
var view = new (CardView.extend({ var view = new (CardView.extend({
cardClass: 'test-card', cardClass: 'test-card',
pennant: 'Pennant',
title: 'A test title', title: 'A test title',
description: 'A test description', description: 'A test description',
actionClass: 'test-action', actionClass: 'test-action',
...@@ -57,9 +66,10 @@ ...@@ -57,9 +66,10 @@
verifyContent(view); verifyContent(view);
}); });
it('can have functions for cardClass, title, description, and action', function () { it('can have functions for cardClass, pennant, title, description, and action', function () {
var view = new (CardView.extend({ var view = new (CardView.extend({
cardClass: function () { return 'test-card'; }, cardClass: function () { return 'test-card'; },
pennant: function () { return 'Pennant'; },
title: function () { return 'A test title'; }, title: function () { return 'A test title'; },
description: function () { return 'A test description'; }, description: function () { return 'A test description'; },
actionClass: function () { return 'test-action'; }, actionClass: function () { return 'test-action'; },
......
...@@ -31,6 +31,7 @@ ...@@ -31,6 +31,7 @@
'backbone': 'xmodule_js/common_static/js/vendor/backbone-min', 'backbone': 'xmodule_js/common_static/js/vendor/backbone-min',
'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min', 'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min',
'backbone.paginator': 'xmodule_js/common_static/js/vendor/backbone.paginator.min', 'backbone.paginator': 'xmodule_js/common_static/js/vendor/backbone.paginator.min',
'URI': 'xmodule_js/common_static/js/vendor/URI.min',
"backbone-super": "js/vendor/backbone-super", "backbone-super": "js/vendor/backbone-super",
'tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min', 'tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min',
'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce', 'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce',
......
...@@ -57,6 +57,7 @@ lib_paths: ...@@ -57,6 +57,7 @@ lib_paths:
- xmodule_js/common_static/js/vendor/underscore-min.js - xmodule_js/common_static/js/vendor/underscore-min.js
- xmodule_js/common_static/js/vendor/underscore.string.min.js - xmodule_js/common_static/js/vendor/underscore.string.min.js
- xmodule_js/common_static/js/vendor/backbone-min.js - xmodule_js/common_static/js/vendor/backbone-min.js
- xmodule_js/common_static/js/vendor/backbone.paginator.min.js
- xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min.js - xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min.js
- xmodule_js/common_static/js/test/i18n.js - xmodule_js/common_static/js/test/i18n.js
- xmodule_js/common_static/js/vendor/date.js - xmodule_js/common_static/js/vendor/date.js
......
...@@ -49,6 +49,7 @@ ...@@ -49,6 +49,7 @@
"text": 'js/vendor/requirejs/text', "text": 'js/vendor/requirejs/text',
"backbone": "js/vendor/backbone-min", "backbone": "js/vendor/backbone-min",
"backbone-super": "js/vendor/backbone-super", "backbone-super": "js/vendor/backbone-super",
"backbone.paginator": "js/vendor/backbone.paginator.min",
"underscore.string": "js/vendor/underscore.string.min", "underscore.string": "js/vendor/underscore.string.min",
// Files needed by OVA // Files needed by OVA
"annotator": "js/vendor/ova/annotator-full", "annotator": "js/vendor/ova/annotator-full",
...@@ -89,6 +90,10 @@ ...@@ -89,6 +90,10 @@
deps: ["underscore", "jquery"], deps: ["underscore", "jquery"],
exports: "Backbone" exports: "Backbone"
}, },
"backbone.paginator": {
deps: ["backbone"],
exports: "Backbone.Paginator"
},
"backbone-super": { "backbone-super": {
deps: ["backbone"] deps: ["backbone"]
}, },
......
<div class="card-core-wrapper"> <div class="wrapper-card-core">
<div class="card-core"> <div class="card-core">
<% if (pennant) { %>
<small class="card-type"><%- pennant %></small>
<% } %>
<h3 class="card-title"><%- title %></h3> <h3 class="card-title"><%- title %></h3>
<p class="card-description"><%- description %></p> <p class="card-description"><%- description %></p>
</div> </div>
</div>
<div class="wrapper-card-meta has-actions">
<div class="card-meta">
</div>
<div class="card-actions"> <div class="card-actions">
<a class="action <%= action_class %>" href="<%= action_url %>"><%= action_content %></a> <a class="action <%= action_class %>" href="<%= action_url %>"><%= action_content %></a>
</div> </div>
</div> </div>
<div class="card-meta-wrapper">
<div class="card-meta-details">
</div>
</div>
<div class="card-core-wrapper">
<div class="card-core">
<h3 class="card-title"><%- title %></h3>
<p class="card-description"><%- description %></p>
</div>
</div>
<div class="card-meta-wrapper has-actions">
<div class="card-meta-details">
</div>
<div class="card-actions">
<a class="action <%= action_class %>" href="<%= action_url %>"><%= action_content %></a>
</div>
</div>
...@@ -3,9 +3,30 @@ from rest_framework import pagination, serializers ...@@ -3,9 +3,30 @@ from rest_framework import pagination, serializers
class PaginationSerializer(pagination.PaginationSerializer): class PaginationSerializer(pagination.PaginationSerializer):
""" """
Custom PaginationSerializer to include num_pages field Custom PaginationSerializer for openedx.
Adds the following fields:
- num_pages: total number of pages
- current_page: the current page being returned
- start: the index of the first page item within the overall collection
""" """
start_page = 1 # django Paginator.page objects have 1-based indexes
num_pages = serializers.Field(source='paginator.num_pages') num_pages = serializers.Field(source='paginator.num_pages')
current_page = serializers.SerializerMethodField('get_current_page')
start = serializers.SerializerMethodField('get_start')
sort_order = serializers.SerializerMethodField('get_sort_order')
def get_current_page(self, page):
"""Get the current page"""
return page.number
def get_start(self, page):
"""Get the index of the first page item within the overall collection"""
return (self.get_current_page(page) - self.start_page) * page.paginator.per_page
def get_sort_order(self, page): # pylint: disable=unused-argument
"""Get the order by which this collection was sorted"""
return self.context.get('sort_order')
class CollapsedReferenceSerializer(serializers.HyperlinkedModelSerializer): class CollapsedReferenceSerializer(serializers.HyperlinkedModelSerializer):
......
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