Add generic paging framework

Authors:
  - Daniel Friedman
  - Ben McMorran
  - Peter Fogg

TNL-1892
This commit is contained in:
Ben McMorran
2015-07-02 15:59:26 -04:00
committed by Daniel Friedman
parent 65a36a64df
commit 351e491c82
39 changed files with 1616 additions and 705 deletions

View File

@@ -0,0 +1,219 @@
/**
* 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);

View File

@@ -0,0 +1,44 @@
/**
* 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);

View File

@@ -1,142 +0,0 @@
define(["underscore", "backbone", "gettext", "common/js/components/views/paging_mixin"],
function(_, Backbone, gettext, PagingMixin) {
var PagingView = Backbone.View.extend(PagingMixin).extend({
// takes a Backbone Paginator as a model
sortableColumns: {},
filterableColumns: {},
filterColumn: '',
initialize: function() {
Backbone.View.prototype.initialize.call(this);
var collection = this.collection;
collection.bind('add', _.bind(this.onPageRefresh, this));
collection.bind('remove', _.bind(this.onPageRefresh, this));
collection.bind('reset', _.bind(this.onPageRefresh, this));
},
onPageRefresh: function() {
var sortColumn = this.sortColumn;
this.renderPageItems();
this.$('.column-sort-link').removeClass('current-sort');
this.$('#' + sortColumn).addClass('current-sort');
},
onError: function() {
// Do nothing by default
},
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);
}
},
registerFilterableColumn: function(columnName, displayName, fieldName) {
this.filterableColumns[columnName] = {
displayName: displayName,
fieldName: fieldName
};
},
filterableColumnInfo: function(filterColumn) {
var filterInfo = this.filterableColumns[filterColumn];
if (!filterInfo) {
throw "Unregistered filter column '" + filterInfo + '"';
}
return filterInfo;
},
filterDisplayName: function() {
var filterColumn = this.filterColumn,
filterInfo = this.filterableColumnInfo(filterColumn);
return filterInfo.displayName;
},
setInitialFilterColumn: function(filterColumn) {
var collection = this.collection,
filtertInfo = this.filterableColumns[filterColumn];
collection.filterField = filtertInfo.fieldName;
this.filterColumn = filterColumn;
},
/**
* Registers information about a column that can be sorted.
* @param columnName The element name of the column.
* @param displayName The display name for the column in the current locale.
* @param fieldName The database field name that is represented by this column.
* @param defaultSortDirection The default sort direction for the column
*/
registerSortableColumn: function(columnName, displayName, fieldName, defaultSortDirection) {
this.sortableColumns[columnName] = {
displayName: displayName,
fieldName: fieldName,
defaultSortDirection: defaultSortDirection
};
},
sortableColumnInfo: function(sortColumn) {
var sortInfo = this.sortableColumns[sortColumn];
if (!sortInfo) {
throw "Unregistered sort column '" + sortColumn + '"';
}
return sortInfo;
},
sortDisplayName: function() {
var sortColumn = this.sortColumn,
sortInfo = this.sortableColumnInfo(sortColumn);
return sortInfo.displayName;
},
setInitialSortColumn: function(sortColumn) {
var collection = this.collection,
sortInfo = this.sortableColumns[sortColumn];
collection.sortField = sortInfo.fieldName;
collection.sortDirection = sortInfo.defaultSortDirection;
this.sortColumn = sortColumn;
},
toggleSortOrder: function(sortColumn) {
var collection = this.collection,
sortInfo = this.sortableColumnInfo(sortColumn),
sortField = sortInfo.fieldName,
defaultSortDirection = sortInfo.defaultSortDirection;
if (collection.sortField === sortField) {
collection.sortDirection = collection.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
collection.sortField = sortField;
collection.sortDirection = defaultSortDirection;
}
this.sortColumn = sortColumn;
this.setPage(0);
},
selectFilter: function(filterColumn) {
var collection = this.collection,
filterInfo = this.filterableColumnInfo(filterColumn),
filterField = filterInfo.fieldName,
defaultFilterKey = false;
if (collection.filterField !== filterField) {
collection.filterField = filterField;
}
this.filterColumn = filterColumn;
this.setPage(0);
}
});
return PagingView;
}); // end define();

View File

@@ -1,65 +1,69 @@
define(["underscore", "backbone", "text!common/templates/components/paging-footer.underscore"],
function(_, Backbone, paging_footer_template) {
;(function (define) {
'use strict';
define(["underscore", "gettext", "backbone", "text!common/templates/components/paging-footer.underscore"],
function(_, gettext, Backbone, paging_footer_template) {
var PagingFooter = Backbone.View.extend({
events : {
"click .next-page-link": "nextPage",
"click .previous-page-link": "previousPage",
"change .page-number-input": "changePage"
},
var PagingFooter = Backbone.View.extend({
events : {
"click .next-page-link": "nextPage",
"click .previous-page-link": "previousPage",
"change .page-number-input": "changePage"
},
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));
this.render();
},
initialize: function(options) {
this.collection = options.collection;
this.hideWhenOnePage = options.hideWhenOnePage || false;
this.collection.bind('add', _.bind(this.render, this));
this.collection.bind('remove', _.bind(this.render, this));
this.collection.bind('reset', _.bind(this.render, this));
this.render();
},
render: function() {
var view = this.view,
collection = view.collection,
currentPage = collection.currentPage,
lastPage = collection.totalPages - 1;
this.$el.html(_.template(paging_footer_template, {
current_page: collection.currentPage,
total_pages: collection.totalPages
}));
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;
},
render: function() {
var onFirstPage = !this.collection.hasPreviousPage(),
onLastPage = !this.collection.hasNextPage();
if (this.hideWhenOnePage) {
if (this.collection.totalPages <= 1) {
this.$el.addClass('hidden');
} else if (this.$el.hasClass('hidden')) {
this.$el.removeClass('hidden');
}
}
this.$el.html(_.template(paging_footer_template, {
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() {
var view = this.view,
collection = view.collection,
currentPage = collection.currentPage + 1,
pageInput = this.$("#page-number-input"),
pageNumber = parseInt(pageInput.val(), 10);
if (pageNumber > collection.totalPages) {
pageNumber = false;
changePage: function() {
var collection = this.collection,
currentPage = collection.getPage(),
pageInput = this.$("#page-number-input"),
pageNumber = parseInt(pageInput.val(), 10),
validInput = true;
if (!pageNumber || pageNumber > collection.totalPages || pageNumber < 1) {
validInput = false;
}
// If we still have a page number by this point,
// and it's not the current page, load it.
if (validInput && pageNumber !== currentPage) {
collection.setPage(pageNumber);
}
pageInput.val(''); // Clear the value as the label will show beneath it
},
nextPage: function() {
this.collection.nextPage();
},
previousPage: function() {
this.collection.previousPage();
}
if (pageNumber <= 0) {
pageNumber = false;
}
// If we still have a page number by this point,
// and it's not the current page, load it.
if (pageNumber && pageNumber !== currentPage) {
view.setPage(pageNumber - 1);
}
pageInput.val(""); // Clear the value as the label will show beneath it
},
});
nextPage: function() {
this.view.nextPage();
},
previousPage: function() {
this.view.previousPage();
}
});
return PagingFooter;
}); // end define();
return PagingFooter;
}); // end define();
}).call(this, define || RequireJS.define);

View File

@@ -1,113 +1,37 @@
define(["underscore", "backbone", "gettext", "text!common/templates/components/paging-header.underscore"],
function(_, Backbone, gettext, paging_header_template) {
;(function (define) {
'use strict';
define([
'backbone',
'underscore',
'gettext',
'text!common/templates/components/paging-header.underscore'
], function (Backbone, _, gettext, headerTemplate) {
var PagingHeader = Backbone.View.extend({
events : {
"click .next-page-link": "nextPage",
"click .previous-page-link": "previousPage"
initialize: function (options) {
this.collections = options.collection;
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;
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);
render: function () {
var message,
start = this.collection.start,
end = start + this.collection.length,
num_items = this.collection.totalCount,
context = {first_index: Math.min(start + 1, end), last_index: end, num_items: num_items};
if (end <= 1) {
message = interpolate(gettext('Showing %(first_index)s out of %(num_items)s total'), context, true);
} else {
message = interpolate(
gettext('Showing %(first_index)s-%(last_index)s out of %(num_items)s total'),
context, true
);
}
this.$el.html(_.template(headerTemplate, {message: message}));
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();
});
}).call(this, define || RequireJS.define);

View File

@@ -1,37 +0,0 @@
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;
});

View File

@@ -0,0 +1,90 @@
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([]);
});
});
});

View File

@@ -1,38 +0,0 @@
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;
});

View File

@@ -0,0 +1,247 @@
define(['jquery',
'backbone',
'underscore',
'URI',
'common/js/components/collections/paging_collection',
'common/js/spec_helpers/ajax_helpers',
'common/js/spec_helpers/spec_helpers'
],
function ($, Backbone, _, URI, PagingCollection, AjaxHelpers, SpecHelpers) {
'use strict';
describe('PagingCollection', function () {
var collection, requests, server, assertQueryParams;
server = {
isZeroIndexed: false,
count: 43,
respond: function () {
var params = (new URI(requests[requests.length - 1].url)).query(true),
page = parseInt(params['page'], 10),
page_size = parseInt(params['page_size'], 10),
page_count = Math.ceil(this.count / page_size);
// Make zeroPage consistently start at zero for ease of calculation
var zeroPage = page - (this.isZeroIndexed ? 0 : 1);
if (zeroPage < 0 || zeroPage > page_count) {
AjaxHelpers.respondWithError(requests, 404, {}, requests.length - 1);
} else {
AjaxHelpers.respondWithJson(requests, {
'count': this.count,
'current_page': page,
'num_pages': page_count,
'start': zeroPage * page_size,
'results': []
}, requests.length - 1);
}
}
};
assertQueryParams = function (params) {
var urlParams = (new URI(requests[requests.length - 1].url)).query(true);
_.each(params, function (value, key) {
expect(urlParams[key]).toBe(value);
});
};
beforeEach(function () {
collection = new PagingCollection();
collection.perPage = 10;
requests = AjaxHelpers.requests(this);
server.isZeroIndexed = false;
server.count = 43;
});
it('can register sortable fields', function () {
collection.registerSortableField('test_field', 'Test Field');
expect('test_field' in collection.sortableFields).toBe(true);
expect(collection.sortableFields['test_field'].displayName).toBe('Test Field');
});
it('can register filterable fields', function () {
collection.registerFilterableField('test_field', 'Test Field');
expect('test_field' in collection.filterableFields).toBe(true);
expect(collection.filterableFields['test_field'].displayName).toBe('Test Field');
});
it('sets the sort field based on the server response', function () {
var sort_order = 'my_sort_order';
collection = new PagingCollection({sort_order: sort_order}, {parse: true});
expect(collection.sortField).toBe(sort_order);
});
it('can set the sort field', function () {
collection.registerSortableField('test_field', 'Test Field');
collection.setSortField('test_field', false);
expect(requests.length).toBe(1);
assertQueryParams({'sort_order': 'test_field'});
expect(collection.sortField).toBe('test_field');
expect(collection.sortDisplayName()).toBe('Test Field');
});
it('can set the filter field', function () {
collection.registerFilterableField('test_field', 'Test Field');
collection.setFilterField('test_field');
expect(requests.length).toBe(1);
// The default implementation does not send any query params for filtering
expect(collection.filterField).toBe('test_field');
expect(collection.filterDisplayName()).toBe('Test Field');
});
it('can set the sort direction', function () {
collection.setSortDirection(PagingCollection.SortDirection.ASCENDING);
expect(requests.length).toBe(1);
// The default implementation does not send any query params for sort direction
expect(collection.sortDirection).toBe(PagingCollection.SortDirection.ASCENDING);
collection.setSortDirection(PagingCollection.SortDirection.DESCENDING);
expect(requests.length).toBe(2);
expect(collection.sortDirection).toBe(PagingCollection.SortDirection.DESCENDING);
});
it('can toggle the sort direction when setting the sort field', function () {
collection.registerSortableField('test_field', 'Test Field');
collection.registerSortableField('test_field_2', 'Test Field 2');
collection.setSortField('test_field', true);
expect(collection.sortDirection).toBe(PagingCollection.SortDirection.DESCENDING);
collection.setSortField('test_field', true);
expect(collection.sortDirection).toBe(PagingCollection.SortDirection.ASCENDING);
collection.setSortField('test_field', true);
expect(collection.sortDirection).toBe(PagingCollection.SortDirection.DESCENDING);
collection.setSortField('test_field_2', true);
expect(collection.sortDirection).toBe(PagingCollection.SortDirection.DESCENDING);
});
SpecHelpers.withData({
'queries with page, page_size, and sort_order parameters when zero indexed': [true, 2],
'queries with page, page_size, and sort_order parameters when one indexed': [false, 3],
}, function (isZeroIndexed, page) {
collection.isZeroIndexed = isZeroIndexed;
collection.perPage = 5;
collection.sortField = 'test_field';
collection.setPage(3);
assertQueryParams({'page': page.toString(), 'page_size': '5', 'sort_order': 'test_field'});
});
SpecHelpers.withConfiguration({
'using a zero indexed collection': [true],
'using a one indexed collection': [false]
}, function (isZeroIndexed) {
collection.isZeroIndexed = isZeroIndexed;
server.isZeroIndexed = isZeroIndexed;
}, function () {
describe('setPage', function() {
it('triggers a reset event when the page changes successfully', function () {
var resetTriggered = false;
collection.on('reset', function () { resetTriggered = true; });
collection.setPage(3);
server.respond();
expect(resetTriggered).toBe(true);
});
it('triggers an error event when the requested page is out of range', function () {
var errorTriggered = false;
collection.on('error', function () { errorTriggered = true; });
collection.setPage(17);
server.respond();
expect(errorTriggered).toBe(true);
});
it('triggers an error event if the server responds with a 500', function () {
var errorTriggered = false;
collection.on('error', function () { errorTriggered = true; });
collection.setPage(2);
expect(collection.getPage()).toBe(2);
server.respond();
collection.setPage(3);
AjaxHelpers.respondWithError(requests, 500, {}, requests.length - 1);
expect(errorTriggered).toBe(true);
expect(collection.getPage()).toBe(2);
});
});
describe('getPage', function () {
it('returns the correct page', function () {
collection.setPage(1);
server.respond();
expect(collection.getPage()).toBe(1);
collection.setPage(3);
server.respond();
expect(collection.getPage()).toBe(3);
});
});
describe('hasNextPage', function () {
SpecHelpers.withData(
{
'returns false for a single page': [1, 3, false],
'returns true on the first page': [1, 43, true],
'returns true on the penultimate page': [4, 43, true],
'returns false on the last page': [5, 43, false]
},
function (page, count, result) {
server.count = count;
collection.setPage(page);
server.respond();
expect(collection.hasNextPage()).toBe(result);
}
);
});
describe('hasPreviousPage', function () {
SpecHelpers.withData(
{
'returns false for a single page': [1, 3, false],
'returns true on the last page': [5, 43, true],
'returns true on the second page': [2, 43, true],
'returns false on the first page': [1, 43, false]
},
function (page, count, result) {
server.count = count;
collection.setPage(page);
server.respond();
expect(collection.hasPreviousPage()).toBe(result);
}
);
});
describe('nextPage', function () {
SpecHelpers.withData(
{
'advances to the next page': [2, 43, 3],
'silently fails on the last page': [5, 43, 5]
},
function (page, count, newPage) {
server.count = count;
collection.setPage(page);
server.respond();
expect(collection.getPage()).toBe(page);
collection.nextPage();
if (requests.length > 1) {
server.respond();
}
expect(collection.getPage()).toBe(newPage);
}
);
});
describe('previousPage', function () {
SpecHelpers.withData(
{
'moves to the previous page': [2, 43, 1],
'silently fails on the first page': [1, 43, 1]
},
function (page, count, newPage) {
server.count = count;
collection.setPage(page);
server.respond();
expect(collection.getPage()).toBe(page);
collection.previousPage();
if (requests.length > 1) {
server.respond();
}
expect(collection.getPage()).toBe(newPage);
}
)
});
});
});
}
);

View File

@@ -0,0 +1,178 @@
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('');
});
});
});
});

View File

@@ -0,0 +1,50 @@
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');
});
});
});

View File

@@ -1,572 +0,0 @@
define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
"common/js/components/views/paging", "common/js/components/views/paging_header",
"common/js/components/views/paging_footer", "common/js/spec/components/paging_collection"],
function ($, AjaxHelpers, URI, PagingView, PagingHeader, PagingFooter, PagingCollection) {
var createPageableItem = function(index) {
var id = 'item_' + index;
return {
id: id,
display_name: id,
url: id
};
};
var mockFirstPage = {
items: [
createPageableItem(1),
createPageableItem(2),
createPageableItem(3)
],
pageSize: 3,
totalCount: 4,
page: 0,
start: 0,
end: 2
};
var mockSecondPage = {
items: [
createPageableItem(4)
],
pageSize: 3,
totalCount: 4,
page: 1,
start: 3,
end: 4
};
var mockEmptyPage = {
items: [],
pageSize: 3,
totalCount: 0,
page: 0,
start: 0,
end: 0
};
var respondWithMockItems = function(requests) {
var requestIndex = requests.length - 1;
var request = requests[requestIndex];
var url = new URI(request.url);
var queryParameters = url.query(true); // Returns an object with each query parameter stored as a value
var page = queryParameters.page;
var response = page === "0" ? mockFirstPage : mockSecondPage;
AjaxHelpers.respondWithJson(requests, response, requestIndex);
};
var MockPagingView = PagingView.extend({
renderPageItems: function() {},
initialize : function() {
this.registerSortableColumn('name-col', 'Name', 'name', 'asc');
this.registerSortableColumn('date-col', 'Date', 'date', 'desc');
this.setInitialSortColumn('date-col');
}
});
describe("Paging", function() {
var pagingView;
beforeEach(function () {
pagingView = new MockPagingView({collection: new PagingCollection()});
});
describe("PagingView", function () {
describe("setPage", function () {
it('can set the current page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
expect(pagingView.collection.currentPage).toBe(0);
pagingView.setPage(1);
respondWithMockItems(requests);
expect(pagingView.collection.currentPage).toBe(1);
});
it('should not change page after a server error', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
pagingView.setPage(1);
requests[1].respond(500);
expect(pagingView.collection.currentPage).toBe(0);
});
});
describe("nextPage", function () {
it('does not move forward after a server error', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
pagingView.nextPage();
requests[1].respond(500);
expect(pagingView.collection.currentPage).toBe(0);
});
it('can move to the next page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
pagingView.nextPage();
respondWithMockItems(requests);
expect(pagingView.collection.currentPage).toBe(1);
});
it('can not move forward from the final page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(1);
respondWithMockItems(requests);
pagingView.nextPage();
expect(requests.length).toBe(1);
});
});
describe("previousPage", function () {
it('can move back a page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(1);
respondWithMockItems(requests);
pagingView.previousPage();
respondWithMockItems(requests);
expect(pagingView.collection.currentPage).toBe(0);
});
it('can not move back from the first page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
pagingView.previousPage();
expect(requests.length).toBe(1);
});
it('does not move back after a server error', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(1);
respondWithMockItems(requests);
pagingView.previousPage();
requests[1].respond(500);
expect(pagingView.collection.currentPage).toBe(1);
});
});
describe("toggleSortOrder", function () {
it('can toggle direction of the current sort', function () {
var requests = AjaxHelpers.requests(this);
expect(pagingView.collection.sortDirection).toBe('desc');
pagingView.toggleSortOrder('date-col');
respondWithMockItems(requests);
expect(pagingView.collection.sortDirection).toBe('asc');
pagingView.toggleSortOrder('date-col');
respondWithMockItems(requests);
expect(pagingView.collection.sortDirection).toBe('desc');
});
it('sets the correct default sort direction for a column', function () {
var requests = AjaxHelpers.requests(this);
pagingView.toggleSortOrder('name-col');
respondWithMockItems(requests);
expect(pagingView.sortDisplayName()).toBe('Name');
expect(pagingView.collection.sortDirection).toBe('asc');
pagingView.toggleSortOrder('date-col');
respondWithMockItems(requests);
expect(pagingView.sortDisplayName()).toBe('Date');
expect(pagingView.collection.sortDirection).toBe('desc');
});
});
describe("sortableColumnInfo", function () {
it('returns the registered info for a column', function () {
pagingView.registerSortableColumn('test-col', 'Test Column', 'testField', 'asc');
var sortInfo = pagingView.sortableColumnInfo('test-col');
expect(sortInfo.displayName).toBe('Test Column');
expect(sortInfo.fieldName).toBe('testField');
expect(sortInfo.defaultSortDirection).toBe('asc');
});
it('throws an exception for an unregistered column', function () {
expect(function() {
pagingView.sortableColumnInfo('no-such-column');
}).toThrow();
});
});
});
describe("PagingHeader", function () {
var pagingHeader;
beforeEach(function () {
pagingHeader = new PagingHeader({view: pagingView});
});
describe("Next page button", function () {
beforeEach(function () {
// Render the page and header so that they can react to events
pagingView.render();
pagingHeader.render();
});
it('does not move forward if a server error occurs', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
pagingHeader.$('.next-page-link').click();
requests[1].respond(500);
expect(pagingView.collection.currentPage).toBe(0);
});
it('can move to the next page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
pagingHeader.$('.next-page-link').click();
respondWithMockItems(requests);
expect(pagingView.collection.currentPage).toBe(1);
});
it('should be enabled when there is at least one more page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
expect(pagingHeader.$('.next-page-link')).not.toHaveClass('is-disabled');
});
it('should be disabled on the final page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(1);
respondWithMockItems(requests);
expect(pagingHeader.$('.next-page-link')).toHaveClass('is-disabled');
});
it('should be disabled on an empty page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
expect(pagingHeader.$('.next-page-link')).toHaveClass('is-disabled');
});
});
describe("Previous page button", function () {
beforeEach(function () {
// Render the page and header so that they can react to events
pagingView.render();
pagingHeader.render();
});
it('does not move back if a server error occurs', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(1);
respondWithMockItems(requests);
pagingHeader.$('.previous-page-link').click();
requests[1].respond(500);
expect(pagingView.collection.currentPage).toBe(1);
});
it('can go back a page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(1);
respondWithMockItems(requests);
pagingHeader.$('.previous-page-link').click();
respondWithMockItems(requests);
expect(pagingView.collection.currentPage).toBe(0);
});
it('should be disabled on the first page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
expect(pagingHeader.$('.previous-page-link')).toHaveClass('is-disabled');
});
it('should be enabled on the second page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(1);
respondWithMockItems(requests);
expect(pagingHeader.$('.previous-page-link')).not.toHaveClass('is-disabled');
});
it('should be disabled for an empty page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
expect(pagingHeader.$('.previous-page-link')).toHaveClass('is-disabled');
});
});
describe("Page metadata section", function() {
it('shows the correct metadata for the current page', function () {
var requests = AjaxHelpers.requests(this),
message;
pagingView.setPage(0);
respondWithMockItems(requests);
message = pagingHeader.$('.meta').html().trim();
expect(message).toBe('<p>Showing <span class="count-current-shown">1-3</span>' +
' out of <span class="count-total">4 total</span>, ' +
'sorted by <span class="sort-order">Date</span> descending</p>');
});
it('shows the correct metadata when sorted ascending', function () {
var requests = AjaxHelpers.requests(this),
message;
pagingView.setPage(0);
pagingView.toggleSortOrder('name-col');
respondWithMockItems(requests);
message = pagingHeader.$('.meta').html().trim();
expect(message).toBe('<p>Showing <span class="count-current-shown">1-3</span>' +
' out of <span class="count-total">4 total</span>, ' +
'sorted by <span class="sort-order">Name</span> ascending</p>');
});
});
describe("Item count label", function () {
it('should show correct count on first page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
expect(pagingHeader.$('.count-current-shown')).toHaveHtml('1-3');
});
it('should show correct count on second page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(1);
respondWithMockItems(requests);
expect(pagingHeader.$('.count-current-shown')).toHaveHtml('4-4');
});
it('should show correct count for an empty collection', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
expect(pagingHeader.$('.count-current-shown')).toHaveHtml('0-0');
});
});
describe("Item total label", function () {
it('should show correct total on the first page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
expect(pagingHeader.$('.count-total')).toHaveText('4 total');
});
it('should show correct total on the second page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(1);
respondWithMockItems(requests);
expect(pagingHeader.$('.count-total')).toHaveText('4 total');
});
it('should show zero total for an empty collection', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
expect(pagingHeader.$('.count-total')).toHaveText('0 total');
});
});
describe("Sort order label", function () {
it('should show correct initial sort order', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
expect(pagingHeader.$('.sort-order')).toHaveText('Date');
});
it('should show updated sort order', function () {
var requests = AjaxHelpers.requests(this);
pagingView.toggleSortOrder('name-col');
respondWithMockItems(requests);
expect(pagingHeader.$('.sort-order')).toHaveText('Name');
});
});
});
describe("PagingFooter", function () {
var pagingFooter;
beforeEach(function () {
pagingFooter = new PagingFooter({view: pagingView});
});
describe("Next page button", function () {
beforeEach(function () {
// Render the page and header so that they can react to events
pagingView.render();
pagingFooter.render();
});
it('does not move forward if a server error occurs', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
pagingFooter.$('.next-page-link').click();
requests[1].respond(500);
expect(pagingView.collection.currentPage).toBe(0);
});
it('can move to the next page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
pagingFooter.$('.next-page-link').click();
respondWithMockItems(requests);
expect(pagingView.collection.currentPage).toBe(1);
});
it('should be enabled when there is at least one more page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
expect(pagingFooter.$('.next-page-link')).not.toHaveClass('is-disabled');
});
it('should be disabled on the final page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(1);
respondWithMockItems(requests);
expect(pagingFooter.$('.next-page-link')).toHaveClass('is-disabled');
});
it('should be disabled on an empty page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
expect(pagingFooter.$('.next-page-link')).toHaveClass('is-disabled');
});
});
describe("Previous page button", function () {
beforeEach(function () {
// Render the page and header so that they can react to events
pagingView.render();
pagingFooter.render();
});
it('does not move back if a server error occurs', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(1);
respondWithMockItems(requests);
pagingFooter.$('.previous-page-link').click();
requests[1].respond(500);
expect(pagingView.collection.currentPage).toBe(1);
});
it('can go back a page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(1);
respondWithMockItems(requests);
pagingFooter.$('.previous-page-link').click();
respondWithMockItems(requests);
expect(pagingView.collection.currentPage).toBe(0);
});
it('should be disabled on the first page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
expect(pagingFooter.$('.previous-page-link')).toHaveClass('is-disabled');
});
it('should be enabled on the second page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(1);
respondWithMockItems(requests);
expect(pagingFooter.$('.previous-page-link')).not.toHaveClass('is-disabled');
});
it('should be disabled for an empty page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
expect(pagingFooter.$('.previous-page-link')).toHaveClass('is-disabled');
});
});
describe("Current page label", function () {
it('should show 1 on the first page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
expect(pagingFooter.$('.current-page')).toHaveText('1');
});
it('should show 2 on the second page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(1);
respondWithMockItems(requests);
expect(pagingFooter.$('.current-page')).toHaveText('2');
});
it('should show 1 for an empty collection', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
expect(pagingFooter.$('.current-page')).toHaveText('1');
});
});
describe("Page total label", function () {
it('should show the correct value with more than one page', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
expect(pagingFooter.$('.total-pages')).toHaveText('2');
});
it('should show page 1 when there are no pageable items', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
expect(pagingFooter.$('.total-pages')).toHaveText('1');
});
});
describe("Page input field", function () {
var input;
beforeEach(function () {
pagingFooter.render();
});
it('should initially have a blank page input', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
expect(pagingFooter.$('.page-number-input')).toHaveValue('');
});
it('should handle invalid page requests', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
pagingFooter.$('.page-number-input').val('abc');
pagingFooter.$('.page-number-input').trigger('change');
expect(pagingView.collection.currentPage).toBe(0);
expect(pagingFooter.$('.page-number-input')).toHaveValue('');
});
it('should switch pages via the input field', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
pagingFooter.$('.page-number-input').val('2');
pagingFooter.$('.page-number-input').trigger('change');
AjaxHelpers.respondWithJson(requests, mockSecondPage);
expect(pagingView.collection.currentPage).toBe(1);
expect(pagingFooter.$('.page-number-input')).toHaveValue('');
});
it('should handle AJAX failures when switching pages via the input field', function () {
var requests = AjaxHelpers.requests(this);
pagingView.setPage(0);
respondWithMockItems(requests);
pagingFooter.$('.page-number-input').val('2');
pagingFooter.$('.page-number-input').trigger('change');
requests[1].respond(500);
expect(pagingView.collection.currentPage).toBe(0);
expect(pagingFooter.$('.page-number-input')).toHaveValue('');
});
});
});
});
});

View File

@@ -0,0 +1,48 @@
/**
* 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
};
});

View File

@@ -1,16 +1,15 @@
<nav class="pagination pagination-full bottom">
<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 page">
<nav class="pagination pagination-full bottom" aria-label="Teams Pagination">
<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>
<div class="nav-item page">
<div class="pagination-form">
<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>
<span class="current-page"><%= current_page + 1 %></span>
<span class="page-divider">/</span>
<span class="current-page"><%= current_page %></span>
<span class="sr">&nbsp;out of&nbsp;</span>
<span class="page-divider" aria-hidden="true">/</span>
<span class="total-pages"><%= total_pages %></span>
</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>
</div>
<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>
</nav>

View File

@@ -1,11 +1,5 @@
<div class="meta-wrap">
<div class="meta">
<%= messageHtml %>
</div>
<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 class="search-tools">
<span class="search-count">
<%= message %>
</span>
</div>

View File

@@ -155,7 +155,10 @@
define([
// 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);