Merge pull request #8593 from edx/dan-f/topic-list-2
Teams Topic List View
This commit is contained in:
@@ -252,6 +252,7 @@ define([
|
||||
"js/spec/views/xblock_string_field_editor_spec",
|
||||
"js/spec/views/xblock_validation_spec",
|
||||
"js/spec/views/license_spec",
|
||||
"js/spec/views/paging_spec",
|
||||
|
||||
"js/spec/views/utils/view_utils_spec",
|
||||
|
||||
@@ -284,4 +285,3 @@ define([
|
||||
# isolation issues with Squire.js
|
||||
# "coffee/spec/views/assets_spec"
|
||||
])
|
||||
|
||||
|
||||
@@ -33,6 +33,44 @@ define(["backbone.paginator", "js/models/asset"], function(BackbonePaginator, As
|
||||
this.currentPage = currentPage;
|
||||
this.start = start;
|
||||
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;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI", "js/views/asset", "js/views/assets",
|
||||
"js/models/asset", "js/collections/asset", "js/spec_helpers/view_helpers"],
|
||||
function ($, AjaxHelpers, URI, AssetView, AssetsView, AssetModel, AssetCollection, ViewHelpers) {
|
||||
define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI", "js/views/assets",
|
||||
"js/collections/asset", "js/spec_helpers/view_helpers"],
|
||||
function ($, AjaxHelpers, URI, AssetsView, AssetCollection, ViewHelpers) {
|
||||
|
||||
describe("Assets", function() {
|
||||
var assetsView, mockEmptyAssetsResponse, mockAssetUploadResponse, mockFileUpload,
|
||||
assetLibraryTpl, assetTpl, pagingFooterTpl, pagingHeaderTpl, uploadModalTpl;
|
||||
assetLibraryTpl, assetTpl, uploadModalTpl;
|
||||
|
||||
assetLibraryTpl = readFixtures('asset-library.underscore');
|
||||
assetTpl = readFixtures('asset.underscore');
|
||||
@@ -357,6 +357,96 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI", "js/views/asset
|
||||
$(".upload-modal .file-chooser").fileupload('add', mockFileUpload);
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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"],
|
||||
function ($, _, AjaxHelpers, URI, XBlockInfo, PagedContainer, PagingHeader, PagingFooter, XBlockView) {
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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) {
|
||||
define([
|
||||
"jquery",
|
||||
"common/js/spec_helpers/ajax_helpers",
|
||||
"URI",
|
||||
"js/views/paging",
|
||||
"js/views/paging_header",
|
||||
"common/js/components/collections/paging_collection"
|
||||
], function ($, AjaxHelpers, URI, PagingView, PagingHeader, PagingCollection) {
|
||||
|
||||
var createPageableItem = function(index) {
|
||||
var id = 'item_' + index;
|
||||
@@ -13,34 +17,37 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
};
|
||||
|
||||
var mockFirstPage = {
|
||||
items: [
|
||||
results: [
|
||||
createPageableItem(1),
|
||||
createPageableItem(2),
|
||||
createPageableItem(3)
|
||||
],
|
||||
pageSize: 3,
|
||||
totalCount: 4,
|
||||
num_pages: 2,
|
||||
page_size: 3,
|
||||
current_page: 0,
|
||||
count: 4,
|
||||
page: 0,
|
||||
start: 0,
|
||||
end: 2
|
||||
start: 0
|
||||
};
|
||||
var mockSecondPage = {
|
||||
items: [
|
||||
results: [
|
||||
createPageableItem(4)
|
||||
],
|
||||
pageSize: 3,
|
||||
totalCount: 4,
|
||||
num_pages: 2,
|
||||
page_size: 3,
|
||||
current_page: 1,
|
||||
count: 4,
|
||||
page: 1,
|
||||
start: 3,
|
||||
end: 4
|
||||
start: 3
|
||||
};
|
||||
var mockEmptyPage = {
|
||||
items: [],
|
||||
pageSize: 3,
|
||||
totalCount: 0,
|
||||
results: [],
|
||||
num_pages: 1,
|
||||
page_size: 3,
|
||||
current_page: 0,
|
||||
count: 0,
|
||||
page: 0,
|
||||
start: 0,
|
||||
end: 0
|
||||
start: 0
|
||||
};
|
||||
|
||||
var respondWithMockItems = function(requests) {
|
||||
@@ -66,26 +73,28 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
var pagingView;
|
||||
|
||||
beforeEach(function () {
|
||||
pagingView = new MockPagingView({collection: new PagingCollection()});
|
||||
var collection = new PagingCollection();
|
||||
collection.isZeroIndexed = true;
|
||||
pagingView = new MockPagingView({collection: collection});
|
||||
});
|
||||
|
||||
describe("PagingView", function () {
|
||||
describe("setPage", function () {
|
||||
it('can set the current page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(0);
|
||||
pagingView.setPage(1);
|
||||
respondWithMockItems(requests);
|
||||
expect(pagingView.collection.currentPage).toBe(0);
|
||||
pagingView.setPage(1);
|
||||
pagingView.setPage(2);
|
||||
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);
|
||||
respondWithMockItems(requests);
|
||||
pagingView.setPage(2);
|
||||
requests[1].respond(500);
|
||||
expect(pagingView.collection.currentPage).toBe(0);
|
||||
});
|
||||
@@ -94,7 +103,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
describe("nextPage", function () {
|
||||
it('does not move forward after a server error', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(0);
|
||||
pagingView.setPage(1);
|
||||
respondWithMockItems(requests);
|
||||
pagingView.nextPage();
|
||||
requests[1].respond(500);
|
||||
@@ -103,7 +112,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
|
||||
it('can move to the next page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(0);
|
||||
pagingView.setPage(1);
|
||||
respondWithMockItems(requests);
|
||||
pagingView.nextPage();
|
||||
respondWithMockItems(requests);
|
||||
@@ -112,7 +121,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
|
||||
it('can not move forward from the final page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(1);
|
||||
pagingView.setPage(2);
|
||||
respondWithMockItems(requests);
|
||||
pagingView.nextPage();
|
||||
expect(requests.length).toBe(1);
|
||||
@@ -123,7 +132,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
|
||||
it('can move back a page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(1);
|
||||
pagingView.setPage(2);
|
||||
respondWithMockItems(requests);
|
||||
pagingView.previousPage();
|
||||
respondWithMockItems(requests);
|
||||
@@ -132,7 +141,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
|
||||
it('can not move back from the first page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(0);
|
||||
pagingView.setPage(1);
|
||||
respondWithMockItems(requests);
|
||||
pagingView.previousPage();
|
||||
expect(requests.length).toBe(1);
|
||||
@@ -140,7 +149,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
|
||||
it('does not move back after a server error', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(1);
|
||||
pagingView.setPage(2);
|
||||
respondWithMockItems(requests);
|
||||
pagingView.previousPage();
|
||||
requests[1].respond(500);
|
||||
@@ -208,7 +217,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
|
||||
it('does not move forward if a server error occurs', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(0);
|
||||
pagingView.setPage(1);
|
||||
respondWithMockItems(requests);
|
||||
pagingHeader.$('.next-page-link').click();
|
||||
requests[1].respond(500);
|
||||
@@ -217,7 +226,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
|
||||
it('can move to the next page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(0);
|
||||
pagingView.setPage(1);
|
||||
respondWithMockItems(requests);
|
||||
pagingHeader.$('.next-page-link').click();
|
||||
respondWithMockItems(requests);
|
||||
@@ -226,14 +235,14 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
|
||||
it('should be enabled when there is at least one more page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(0);
|
||||
pagingView.setPage(1);
|
||||
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);
|
||||
pagingView.setPage(2);
|
||||
respondWithMockItems(requests);
|
||||
expect(pagingHeader.$('.next-page-link')).toHaveClass('is-disabled');
|
||||
});
|
||||
@@ -255,7 +264,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
|
||||
it('does not move back if a server error occurs', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(1);
|
||||
pagingView.setPage(2);
|
||||
respondWithMockItems(requests);
|
||||
pagingHeader.$('.previous-page-link').click();
|
||||
requests[1].respond(500);
|
||||
@@ -264,7 +273,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
|
||||
it('can go back a page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(1);
|
||||
pagingView.setPage(2);
|
||||
respondWithMockItems(requests);
|
||||
pagingHeader.$('.previous-page-link').click();
|
||||
respondWithMockItems(requests);
|
||||
@@ -273,21 +282,21 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
|
||||
it('should be disabled on the first page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(0);
|
||||
pagingView.setPage(1);
|
||||
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);
|
||||
pagingView.setPage(2);
|
||||
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);
|
||||
pagingView.setPage(1);
|
||||
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
|
||||
expect(pagingHeader.$('.previous-page-link')).toHaveClass('is-disabled');
|
||||
});
|
||||
@@ -297,7 +306,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
it('shows the correct metadata for the current page', function () {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
message;
|
||||
pagingView.setPage(0);
|
||||
pagingView.setPage(1);
|
||||
respondWithMockItems(requests);
|
||||
message = pagingHeader.$('.meta').html().trim();
|
||||
expect(message).toBe('<p>Showing <span class="count-current-shown">1-3</span>' +
|
||||
@@ -308,7 +317,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
it('shows the correct metadata when sorted ascending', function () {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
message;
|
||||
pagingView.setPage(0);
|
||||
pagingView.setPage(1);
|
||||
pagingView.toggleSortOrder('name-col');
|
||||
respondWithMockItems(requests);
|
||||
message = pagingHeader.$('.meta').html().trim();
|
||||
@@ -321,21 +330,21 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
describe("Item count label", function () {
|
||||
it('should show correct count on first page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(0);
|
||||
pagingView.setPage(1);
|
||||
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);
|
||||
pagingView.setPage(2);
|
||||
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);
|
||||
pagingView.setPage(1);
|
||||
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
|
||||
expect(pagingHeader.$('.count-current-shown')).toHaveHtml('0-0');
|
||||
});
|
||||
@@ -344,21 +353,21 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
describe("Item total label", function () {
|
||||
it('should show correct total on the first page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(0);
|
||||
pagingView.setPage(1);
|
||||
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);
|
||||
pagingView.setPage(2);
|
||||
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);
|
||||
pagingView.setPage(1);
|
||||
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
|
||||
expect(pagingHeader.$('.count-total')).toHaveText('0 total');
|
||||
});
|
||||
@@ -367,7 +376,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
describe("Sort order label", function () {
|
||||
it('should show correct initial sort order', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingView.setPage(0);
|
||||
pagingView.setPage(1);
|
||||
respondWithMockItems(requests);
|
||||
expect(pagingHeader.$('.sort-order')).toHaveText('Date');
|
||||
});
|
||||
@@ -380,193 +389,5 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/models/asset", "common/js/components/views/paging",
|
||||
"js/views/asset", "common/js/components/views/paging_header", "common/js/components/views/paging_footer",
|
||||
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/models/asset", "js/views/paging",
|
||||
"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",
|
||||
"text!templates/asset-library.underscore",
|
||||
"jquery.fileupload-process", "jquery.fileupload-validate"],
|
||||
@@ -71,7 +71,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/models/asset
|
||||
tableBody = this.$('#asset-table-body');
|
||||
this.tableBody = tableBody;
|
||||
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.pagingFooter.render();
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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",
|
||||
"common/js/components/views/paging_footer", "common/js/components/views/paging_mixin"],
|
||||
function ($, _, ViewUtils, ContainerView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter, PagingMixin) {
|
||||
var PagedContainerView = ContainerView.extend(PagingMixin).extend({
|
||||
"js/views/feedback_notification", "js/views/paging_header", "common/js/components/views/paging_footer"],
|
||||
function ($, _, ViewUtils, ContainerView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter) {
|
||||
var PagedContainerView = ContainerView.extend({
|
||||
initialize: function(options){
|
||||
var self = this;
|
||||
ContainerView.prototype.initialize.call(this);
|
||||
@@ -27,7 +26,33 @@ define(["jquery", "underscore", "js/views/utils/view_utils", "js/views/container
|
||||
// of paginator, on the current page.
|
||||
size: function() { return self.collection._size; },
|
||||
// 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
|
||||
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){
|
||||
// We have the Django template sneak us the pagination information,
|
||||
// and we load it from a div here.
|
||||
@@ -119,7 +161,7 @@ define(["jquery", "underscore", "js/views/utils/view_utils", "js/views/container
|
||||
el: this.$el.find('.container-paging-header')
|
||||
});
|
||||
this.pagingFooter = new PagingFooter({
|
||||
view: this,
|
||||
collection: this.collection,
|
||||
el: this.$el.find('.container-paging-footer')
|
||||
});
|
||||
|
||||
|
||||
143
cms/static/js/views/paging.js
Normal file
143
cms/static/js/views/paging.js
Normal file
@@ -0,0 +1,143 @@
|
||||
;(function (define) {
|
||||
'use strict';
|
||||
define(["underscore", "backbone", "gettext"],
|
||||
function(_, Backbone, gettext) {
|
||||
|
||||
var PagingView = Backbone.View.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));
|
||||
collection.bind('error', _.bind(this.onError, this));
|
||||
collection.bind('page_changed', function () { window.scrollTo(0, 0); });
|
||||
},
|
||||
|
||||
onPageRefresh: function() {
|
||||
var sortColumn = this.collection.sortColumn;
|
||||
this.renderPageItems();
|
||||
this.$('.column-sort-link').removeClass('current-sort');
|
||||
this.$('#' + sortColumn).addClass('current-sort');
|
||||
},
|
||||
|
||||
onError: function() {
|
||||
// Do nothing by default
|
||||
},
|
||||
|
||||
setPage: function (page) {
|
||||
this.collection.setPage(page);
|
||||
},
|
||||
|
||||
nextPage: function () {
|
||||
this.collection.nextPage();
|
||||
},
|
||||
|
||||
previousPage: function () {
|
||||
this.collection.previousPage();
|
||||
},
|
||||
|
||||
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,
|
||||
filterInfo = this.filterableColumns[filterColumn];
|
||||
collection.filterField = filterInfo.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.collection.setPage(1);
|
||||
},
|
||||
|
||||
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.collection.setPage(1);
|
||||
}
|
||||
});
|
||||
return PagingView;
|
||||
}); // end define();
|
||||
}).call(this, define || RequireJS.define);
|
||||
113
cms/static/js/views/paging_header.js
Normal file
113
cms/static/js/views/paging_header.js
Normal file
@@ -0,0 +1,113 @@
|
||||
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:
|
||||
- xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js
|
||||
- xmodule_js/common_static/js/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.fileupload.js
|
||||
- xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process.js
|
||||
|
||||
@@ -19,11 +19,13 @@
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
display: block;
|
||||
border: 0;
|
||||
padding: ($baseline/4) ($baseline*0.75);
|
||||
|
||||
&.previous {
|
||||
|
||||
11
cms/templates/js/paging-header.underscore
Normal file
11
cms/templates/js/paging-header.underscore
Normal file
@@ -0,0 +1,11 @@
|
||||
<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>
|
||||
@@ -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);
|
||||
44
common/static/common/js/components/views/list.js
Normal file
44
common/static/common/js/components/views/list.js
Normal 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);
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
90
common/static/common/js/spec/components/list_spec.js
Normal file
90
common/static/common/js/spec/components/list_spec.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
)
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
178
common/static/common/js/spec/components/paging_footer_spec.js
Normal file
178
common/static/common/js/spec/components/paging_footer_spec.js
Normal 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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
48
common/static/common/js/spec_helpers/spec_helpers.js
Normal file
48
common/static/common/js/spec_helpers/spec_helpers.js
Normal 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
|
||||
};
|
||||
});
|
||||
@@ -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"> out of </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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
59
common/test/acceptance/pages/common/paging.py
Normal file
59
common/test/acceptance/pages/common/paging.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Common mixin for paginated UIs.
|
||||
"""
|
||||
|
||||
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
|
||||
class PaginatedUIMixin(object):
|
||||
"""Common methods used for paginated UI."""
|
||||
|
||||
PAGINATION_FOOTER_CSS = 'nav.bottom'
|
||||
PAGE_NUMBER_INPUT_CSS = 'input#page-number-input'
|
||||
NEXT_PAGE_BUTTON_CSS = 'button.next-page-link'
|
||||
PREVIOUS_PAGE_BUTTON_CSS = 'button.previous-page-link'
|
||||
PAGINATION_HEADER_TEXT_CSS = 'div.search-tools'
|
||||
CURRENT_PAGE_NUMBER_CSS = 'span.current-page'
|
||||
|
||||
def get_pagination_header_text(self):
|
||||
"""Return the text showing which items the user is currently viewing."""
|
||||
return self.q(css=self.PAGINATION_HEADER_TEXT_CSS).text[0]
|
||||
|
||||
def pagination_controls_visible(self):
|
||||
"""Return true if the pagination controls in the footer are visible."""
|
||||
footer_nav = self.q(css=self.PAGINATION_FOOTER_CSS).results[0]
|
||||
# The footer element itself is non-generic, so check above it
|
||||
footer_el = footer_nav.find_element_by_xpath('..')
|
||||
return 'hidden' not in footer_el.get_attribute('class').split()
|
||||
|
||||
def get_current_page_number(self):
|
||||
"""Return the the current page number."""
|
||||
return int(self.q(css=self.CURRENT_PAGE_NUMBER_CSS).text[0])
|
||||
|
||||
def go_to_page(self, page_number):
|
||||
"""Go to the given page_number in the paginated list results."""
|
||||
self.q(css=self.PAGE_NUMBER_INPUT_CSS).results[0].send_keys(unicode(page_number), Keys.ENTER)
|
||||
self.wait_for_ajax()
|
||||
|
||||
def press_next_page_button(self):
|
||||
"""Press the next page button in the paginated list results."""
|
||||
self.q(css=self.NEXT_PAGE_BUTTON_CSS).click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def press_previous_page_button(self):
|
||||
"""Press the previous page button in the paginated list results."""
|
||||
self.q(css=self.PREVIOUS_PAGE_BUTTON_CSS).click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def is_next_page_button_enabled(self):
|
||||
"""Return whether the 'next page' button can be clicked."""
|
||||
return self.is_enabled(self.NEXT_PAGE_BUTTON_CSS)
|
||||
|
||||
def is_previous_page_button_enabled(self):
|
||||
"""Return whether the 'previous page' button can be clicked."""
|
||||
return self.is_enabled(self.PREVIOUS_PAGE_BUTTON_CSS)
|
||||
|
||||
def is_enabled(self, css):
|
||||
"""Return whether the given element is not disabled."""
|
||||
return 'is-disabled' not in self.q(css=css).attrs('class')[0]
|
||||
@@ -4,6 +4,11 @@ Teams page.
|
||||
"""
|
||||
|
||||
from .course_page import CoursePage
|
||||
from ..common.paging import PaginatedUIMixin
|
||||
|
||||
|
||||
TOPIC_CARD_CSS = 'div.wrapper-card-core'
|
||||
BROWSE_BUTTON_CSS = 'a.nav-item[data-index="1"]'
|
||||
|
||||
|
||||
class TeamsPage(CoursePage):
|
||||
@@ -24,3 +29,27 @@ class TeamsPage(CoursePage):
|
||||
description="Body text is present"
|
||||
)
|
||||
return self.q(css=main_page_content_css).text[0]
|
||||
|
||||
def browse_topics(self):
|
||||
""" View the Browse tab of the Teams page. """
|
||||
self.q(css=BROWSE_BUTTON_CSS).click()
|
||||
|
||||
|
||||
class BrowseTopicsPage(CoursePage, PaginatedUIMixin):
|
||||
"""
|
||||
The 'Browse' tab of the Teams page.
|
||||
"""
|
||||
|
||||
url_path = "teams/#browse"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""Check if the Browse tab is being viewed."""
|
||||
button_classes = self.q(css=BROWSE_BUTTON_CSS).attrs('class')
|
||||
if len(button_classes) == 0:
|
||||
return False
|
||||
return 'is-active' in button_classes[0]
|
||||
|
||||
@property
|
||||
def topic_cards(self):
|
||||
"""Return a list of the topic cards present on the page."""
|
||||
return self.q(css=TOPIC_CARD_CSS).results
|
||||
|
||||
@@ -17,7 +17,7 @@ class PaginatedMixin(object):
|
||||
To specify a specific arrow, pass an iterable with a single element, 'next' or 'previous'.
|
||||
"""
|
||||
return all([
|
||||
self.q(css='nav.%s * a.%s-page-link.is-disabled' % (position, arrow))
|
||||
self.q(css='nav.%s * .%s-page-link.is-disabled' % (position, arrow))
|
||||
for arrow in arrows
|
||||
])
|
||||
|
||||
@@ -25,14 +25,14 @@ class PaginatedMixin(object):
|
||||
"""
|
||||
Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'.
|
||||
"""
|
||||
self.q(css='nav.%s * a.previous-page-link' % position)[0].click()
|
||||
self.q(css='nav.%s * .previous-page-link' % position)[0].click()
|
||||
self.wait_until_ready()
|
||||
|
||||
def move_forward(self, position):
|
||||
"""
|
||||
Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'.
|
||||
"""
|
||||
self.q(css='nav.%s * a.next-page-link' % position)[0].click()
|
||||
self.q(css='nav.%s * .next-page-link' % position)[0].click()
|
||||
self.wait_until_ready()
|
||||
|
||||
def go_to_page(self, number):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Acceptance tests for the teams feature.
|
||||
"""
|
||||
from ..helpers import UniqueCourseTest
|
||||
from ...pages.lms.teams import TeamsPage
|
||||
from ...pages.lms.teams import TeamsPage, BrowseTopicsPage
|
||||
from nose.plugins.attrib import attr
|
||||
from ...fixtures.course import CourseFixture
|
||||
from ...pages.lms.tab_nav import TabNavPage
|
||||
@@ -21,7 +21,10 @@ class TeamsTabTest(UniqueCourseTest):
|
||||
self.tab_nav = TabNavPage(self.browser)
|
||||
self.course_info_page = CourseInfoPage(self.browser, self.course_id)
|
||||
self.teams_page = TeamsPage(self.browser, self.course_id)
|
||||
self.test_topic = {u"name": u"a topic", u"description": u"test topic", u"id": 0}
|
||||
|
||||
def create_topics(self, num_topics):
|
||||
"""Create `num_topics` test topics."""
|
||||
return [{u"description": str(i), u"name": str(i), u"id": i} for i in xrange(num_topics)]
|
||||
|
||||
def set_team_configuration(self, configuration, enroll_in_course=True, global_staff=False):
|
||||
"""
|
||||
@@ -75,11 +78,15 @@ class TeamsTabTest(UniqueCourseTest):
|
||||
"""
|
||||
Scenario: teams tab should not be present if student is not enrolled in the course
|
||||
Given there is a course with team configuration and topics
|
||||
|
||||
And I am not enrolled in that course, and am not global staff
|
||||
When I view the course info page
|
||||
Then I should not see the Teams tab
|
||||
"""
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": [self.test_topic]}, enroll_in_course=False)
|
||||
self.set_team_configuration(
|
||||
{u"max_team_size": 10, u"topics": self.create_topics(1)},
|
||||
enroll_in_course=False
|
||||
)
|
||||
self.verify_teams_present(False)
|
||||
|
||||
def test_teams_enabled(self):
|
||||
@@ -90,7 +97,7 @@ class TeamsTabTest(UniqueCourseTest):
|
||||
Then I should see the Teams tab
|
||||
And the correct content should be on the page
|
||||
"""
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": [self.test_topic]})
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": self.create_topics(1)})
|
||||
self.verify_teams_present(True)
|
||||
|
||||
def test_teams_enabled_global_staff(self):
|
||||
@@ -103,6 +110,121 @@ class TeamsTabTest(UniqueCourseTest):
|
||||
And the correct content should be on the page
|
||||
"""
|
||||
self.set_team_configuration(
|
||||
{u"max_team_size": 10, u"topics": [self.test_topic]}, enroll_in_course=False, global_staff=True
|
||||
{u"max_team_size": 10, u"topics": self.create_topics(1)},
|
||||
enroll_in_course=False,
|
||||
global_staff=True
|
||||
)
|
||||
self.verify_teams_present(True)
|
||||
|
||||
|
||||
@attr('shard_5')
|
||||
class BrowseTopicsTest(TeamsTabTest):
|
||||
"""
|
||||
Tests for the Browse tab of the Teams page.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(BrowseTopicsTest, self).setUp()
|
||||
self.topics_page = BrowseTopicsPage(self.browser, self.course_id)
|
||||
|
||||
def test_list_topics(self):
|
||||
"""
|
||||
Scenario: a list of topics should be visible in the "Browse" tab
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I visit the Teams page
|
||||
And I browse topics
|
||||
Then I should see a list of topics for the course
|
||||
"""
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": self.create_topics(2)})
|
||||
self.topics_page.visit()
|
||||
self.assertEqual(len(self.topics_page.topic_cards), 2)
|
||||
self.assertEqual(self.topics_page.get_pagination_header_text(), 'Showing 1-2 out of 2 total')
|
||||
self.assertFalse(self.topics_page.pagination_controls_visible())
|
||||
self.assertFalse(self.topics_page.is_previous_page_button_enabled())
|
||||
self.assertFalse(self.topics_page.is_next_page_button_enabled())
|
||||
|
||||
def test_topic_pagination(self):
|
||||
"""
|
||||
Scenario: a list of topics should be visible in the "Browse" tab, paginated 12 per page
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I visit the Teams page
|
||||
And I browse topics
|
||||
Then I should see only the first 12 topics
|
||||
"""
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": self.create_topics(20)})
|
||||
self.topics_page.visit()
|
||||
self.assertEqual(len(self.topics_page.topic_cards), 12)
|
||||
self.assertEqual(self.topics_page.get_pagination_header_text(), 'Showing 1-12 out of 20 total')
|
||||
self.assertTrue(self.topics_page.pagination_controls_visible())
|
||||
self.assertFalse(self.topics_page.is_previous_page_button_enabled())
|
||||
self.assertTrue(self.topics_page.is_next_page_button_enabled())
|
||||
|
||||
def test_go_to_numbered_page(self):
|
||||
"""
|
||||
Scenario: topics should be able to be navigated by page number
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I visit the Teams page
|
||||
And I browse topics
|
||||
And I enter a valid page number in the page number input
|
||||
Then I should see that page of topics
|
||||
"""
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": self.create_topics(25)})
|
||||
self.topics_page.visit()
|
||||
self.topics_page.go_to_page(3)
|
||||
self.assertEqual(len(self.topics_page.topic_cards), 1)
|
||||
self.assertTrue(self.topics_page.is_previous_page_button_enabled())
|
||||
self.assertFalse(self.topics_page.is_next_page_button_enabled())
|
||||
|
||||
def test_go_to_invalid_page(self):
|
||||
"""
|
||||
Scenario: browsing topics should not respond to invalid page numbers
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I visit the Teams page
|
||||
And I browse topics
|
||||
And I enter an invalid page number in the page number input
|
||||
Then I should stay on the current page
|
||||
"""
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": self.create_topics(13)})
|
||||
self.topics_page.visit()
|
||||
self.topics_page.go_to_page(3)
|
||||
self.assertEqual(self.topics_page.get_current_page_number(), 1)
|
||||
|
||||
def test_page_navigation_buttons(self):
|
||||
"""
|
||||
Scenario: browsing topics should not respond to invalid page numbers
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I visit the Teams page
|
||||
And I browse topics
|
||||
When I press the next page button
|
||||
Then I should move to the next page
|
||||
When I press the previous page button
|
||||
Then I should move to the previous page
|
||||
"""
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": self.create_topics(13)})
|
||||
self.topics_page.visit()
|
||||
self.topics_page.press_next_page_button()
|
||||
self.assertEqual(len(self.topics_page.topic_cards), 1)
|
||||
self.assertEqual(self.topics_page.get_pagination_header_text(), 'Showing 13-13 out of 13 total')
|
||||
self.topics_page.press_previous_page_button()
|
||||
self.assertEqual(len(self.topics_page.topic_cards), 12)
|
||||
self.assertEqual(self.topics_page.get_pagination_header_text(), 'Showing 1-12 out of 13 total')
|
||||
|
||||
def test_topic_description_truncation(self):
|
||||
"""
|
||||
Scenario: excessively long topic descriptions should be truncated so
|
||||
as to fit within a topic card.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
with a long description
|
||||
When I visit the Teams page
|
||||
And I browse topics
|
||||
Then I should see a truncated topic description
|
||||
"""
|
||||
initial_description = "A" + " really" * 50 + " long description"
|
||||
self.set_team_configuration(
|
||||
{u"max_team_size": 1, u"topics": [{"name": "", "id": "", "description": initial_description}]}
|
||||
)
|
||||
self.topics_page.visit()
|
||||
truncated_description = self.topics_page.topic_cards[0].text
|
||||
self.assertLess(len(truncated_description), len(initial_description))
|
||||
self.assertTrue(truncated_description.endswith('...'))
|
||||
self.assertIn(truncated_description.split('...')[0], initial_description)
|
||||
|
||||
23
lms/djangoapps/teams/static/teams/js/collections/topic.js
Normal file
23
lms/djangoapps/teams/static/teams/js/collections/topic.js
Normal file
@@ -0,0 +1,23 @@
|
||||
;(function (define) {
|
||||
'use strict';
|
||||
define(['common/js/components/collections/paging_collection', 'teams/js/models/topic', 'gettext'],
|
||||
function(PagingCollection, TopicModel, gettext) {
|
||||
var TopicCollection = PagingCollection.extend({
|
||||
initialize: function(topics, options) {
|
||||
PagingCollection.prototype.initialize.call(this);
|
||||
|
||||
this.course_id = options.course_id;
|
||||
this.perPage = topics.results.length;
|
||||
this.server_api['course_id'] = function () { return encodeURIComponent(this.course_id); };
|
||||
this.server_api['order_by'] = function () { return this.sortField; };
|
||||
delete this.server_api['sort_order']; // Sort order is not specified for the Team API
|
||||
|
||||
this.registerSortableField('name', gettext('name'));
|
||||
this.registerSortableField('team_count', gettext('team count'));
|
||||
},
|
||||
|
||||
model: TopicModel
|
||||
});
|
||||
return TopicCollection;
|
||||
});
|
||||
}).call(this, define || RequireJS.define);
|
||||
@@ -13,5 +13,5 @@
|
||||
}
|
||||
});
|
||||
return Topic;
|
||||
})
|
||||
});
|
||||
}).call(this, define || RequireJS.define);
|
||||
|
||||
@@ -7,7 +7,7 @@ define(["jquery", "backbone", "teams/js/teams_tab_factory"],
|
||||
|
||||
beforeEach(function() {
|
||||
setFixtures('<section class="teams-content"></section>');
|
||||
teamsTab = new TeamsTabFactory();
|
||||
teamsTab = new TeamsTabFactory({results: []}, '', 'edX/DemoX/Demo_Course');
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
|
||||
@@ -15,7 +15,7 @@ define(['jquery',
|
||||
'name': 'Renewable Energy',
|
||||
'description': 'Explore how changes in <ⓡⓔⓝⓔⓦⓐⓑⓛⓔ> ʎƃɹǝuǝ will affect our lives.',
|
||||
'team_count': 34
|
||||
}),
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,8 +23,8 @@ define(['jquery',
|
||||
expect(view.$el).toHaveClass('square-card');
|
||||
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-meta-details').text()).toContain('34 Teams');
|
||||
expect(view.$el.find('.action').text()).toContain('View');
|
||||
expect(view.$el.find('.card-meta').text()).toContain('34 Teams');
|
||||
expect(view.$el.find('.action .sr').text()).toContain('View Teams in the Renewable Energy Topic');
|
||||
});
|
||||
|
||||
it('navigates when action button is clicked', function () {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
define(['URI', 'underscore', 'common/js/spec_helpers/ajax_helpers', 'teams/js/collections/topic'],
|
||||
function (URI, _, AjaxHelpers, TopicCollection) {
|
||||
'use strict';
|
||||
describe('TopicCollection', function () {
|
||||
var topicCollection;
|
||||
beforeEach(function () {
|
||||
topicCollection = new TopicCollection(
|
||||
{
|
||||
"count": 6,
|
||||
"num_pages": 2,
|
||||
"current_page": 1,
|
||||
"start": 0,
|
||||
"results": [
|
||||
{
|
||||
"description": "asdf description",
|
||||
"name": "asdf",
|
||||
"id": "_asdf"
|
||||
},
|
||||
{
|
||||
"description": "bar description",
|
||||
"name": "bar",
|
||||
"id": "_bar"
|
||||
},
|
||||
{
|
||||
"description": "baz description",
|
||||
"name": "baz",
|
||||
"id": "_baz"
|
||||
},
|
||||
{
|
||||
"description": "foo description",
|
||||
"name": "foo",
|
||||
"id": "_foo"
|
||||
},
|
||||
{
|
||||
"description": "qwerty description",
|
||||
"name": "qwerty",
|
||||
"id": "_qwerty"
|
||||
}
|
||||
],
|
||||
"sort_order": "name"
|
||||
},
|
||||
{course_id: 'my/course/id', parse: true});
|
||||
});
|
||||
|
||||
var testRequestParam = function (self, param, value) {
|
||||
var requests = AjaxHelpers.requests(self),
|
||||
url,
|
||||
params;
|
||||
topicCollection.fetch();
|
||||
expect(requests.length).toBe(1);
|
||||
url = new URI(requests[0].url);
|
||||
params = url.query(true);
|
||||
expect(params[param]).toBe(value);
|
||||
};
|
||||
|
||||
it('sets its perPage based on initial page size', function () {
|
||||
expect(topicCollection.perPage).toBe(5);
|
||||
});
|
||||
|
||||
it('sorts by name', function () {
|
||||
testRequestParam(this, 'order_by', 'name');
|
||||
});
|
||||
|
||||
it('passes a course_id to the server', function () {
|
||||
testRequestParam(this, 'course_id', 'my/course/id');
|
||||
});
|
||||
|
||||
it('URL encodes its course_id ', function () {
|
||||
topicCollection.course_id = 'my+course+id';
|
||||
testRequestParam(this, 'course_id', 'my+course+id');
|
||||
});
|
||||
});
|
||||
});
|
||||
176
lms/djangoapps/teams/static/teams/js/spec/topics_spec.js
Normal file
176
lms/djangoapps/teams/static/teams/js/spec/topics_spec.js
Normal file
@@ -0,0 +1,176 @@
|
||||
define([
|
||||
'common/js/spec_helpers/ajax_helpers', 'teams/js/collections/topic', 'teams/js/views/topics'
|
||||
], function (AjaxHelpers, TopicCollection, TopicsView) {
|
||||
'use strict';
|
||||
describe('TopicsView', function () {
|
||||
var initialTopics, topicCollection, topicsView, nextPageButtonCss;
|
||||
|
||||
nextPageButtonCss = '.next-page-link';
|
||||
|
||||
function generateTopics(startIndex, stopIndex) {
|
||||
return _.map(_.range(startIndex, stopIndex + 1), function (i) {
|
||||
return {
|
||||
"description": "description " + i,
|
||||
"name": "topic " + i,
|
||||
"id": "id " + i,
|
||||
"team_count": 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
setFixtures('<div class="topics-container"></div>');
|
||||
initialTopics = generateTopics(1, 5);
|
||||
topicCollection = new TopicCollection(
|
||||
{
|
||||
"count": 6,
|
||||
"num_pages": 2,
|
||||
"current_page": 1,
|
||||
"start": 0,
|
||||
"results": initialTopics
|
||||
},
|
||||
{course_id: 'my/course/id', parse: true}
|
||||
);
|
||||
topicsView = new TopicsView({el: '.topics-container', collection: topicCollection}).render();
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that the topics view's header reflects the page we're currently viewing.
|
||||
* @param matchString the header we expect to see
|
||||
*/
|
||||
function expectHeader(matchString) {
|
||||
expect(topicsView.$('.topics-paging-header').text()).toMatch(matchString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the topics list view renders the expected topics
|
||||
* @param expectedTopics an array of topic objects we expect to see
|
||||
*/
|
||||
function expectTopics(expectedTopics) {
|
||||
var topicCards;
|
||||
topicCards = topicsView.$('.topic-card');
|
||||
_.each(expectedTopics, function (topic, index) {
|
||||
var currentCard = topicCards.eq(index);
|
||||
expect(currentCard.text()).toMatch(topic.name);
|
||||
expect(currentCard.text()).toMatch(topic.description);
|
||||
expect(currentCard.text()).toMatch(topic.team_count + ' Teams');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the topics footer reflects the current pagination
|
||||
* @param options a parameters hash containing:
|
||||
* - currentPage: the one-indexed page we expect to be viewing
|
||||
* - totalPages: the total number of pages to page through
|
||||
* - isHidden: whether the footer is expected to be visible
|
||||
*/
|
||||
function expectFooter(options) {
|
||||
var footerEl = topicsView.$('.topics-paging-footer');
|
||||
expect(footerEl.text())
|
||||
.toMatch(new RegExp(options.currentPage + '\\s+out of\\s+\/\\s+' + topicCollection.totalPages));
|
||||
expect(footerEl.hasClass('hidden')).toBe(options.isHidden);
|
||||
}
|
||||
|
||||
it('can render the first of many pages', function () {
|
||||
expectHeader('Showing 1-5 out of 6 total');
|
||||
expectTopics(initialTopics);
|
||||
expectFooter({currentPage: 1, totalPages: 2, isHidden: false});
|
||||
});
|
||||
|
||||
it('can render the only page', function () {
|
||||
initialTopics = generateTopics(1, 1);
|
||||
topicCollection.set(
|
||||
{
|
||||
"count": 1,
|
||||
"num_pages": 1,
|
||||
"current_page": 1,
|
||||
"start": 0,
|
||||
"results": initialTopics
|
||||
},
|
||||
{parse: true}
|
||||
);
|
||||
expectHeader('Showing 1 out of 1 total');
|
||||
expectTopics(initialTopics);
|
||||
expectFooter({currentPage: 1, totalPages: 1, isHidden: true});
|
||||
});
|
||||
|
||||
it('can change to the next page', function () {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
newTopics = generateTopics(1, 1);
|
||||
expectHeader('Showing 1-5 out of 6 total');
|
||||
expectTopics(initialTopics);
|
||||
expectFooter({currentPage: 1, totalPages: 2, isHidden: false});
|
||||
expect(requests.length).toBe(0);
|
||||
topicsView.$(nextPageButtonCss).click();
|
||||
expect(requests.length).toBe(1);
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
"count": 6,
|
||||
"num_pages": 2,
|
||||
"current_page": 2,
|
||||
"start": 5,
|
||||
"results": newTopics
|
||||
});
|
||||
expectHeader('Showing 6-6 out of 6 total');
|
||||
expectTopics(newTopics);
|
||||
expectFooter({currentPage: 2, totalPages: 2, isHidden: false});
|
||||
});
|
||||
|
||||
it('can change to the previous page', function () {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
previousPageTopics;
|
||||
initialTopics = generateTopics(1, 1);
|
||||
topicCollection.set(
|
||||
{
|
||||
"count": 6,
|
||||
"num_pages": 2,
|
||||
"current_page": 2,
|
||||
"start": 5,
|
||||
"results": initialTopics
|
||||
},
|
||||
{parse: true}
|
||||
);
|
||||
expectHeader('Showing 6-6 out of 6 total');
|
||||
expectTopics(initialTopics);
|
||||
expectFooter({currentPage: 2, totalPages: 2, isHidden: false});
|
||||
topicsView.$('.previous-page-link').click();
|
||||
previousPageTopics = generateTopics(1, 5);
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
"count": 6,
|
||||
"num_pages": 2,
|
||||
"current_page": 1,
|
||||
"start": 0,
|
||||
"results": previousPageTopics
|
||||
});
|
||||
expectHeader('Showing 1-5 out of 6 total');
|
||||
expectTopics(previousPageTopics);
|
||||
expectFooter({currentPage: 1, totalPages: 2, isHidden: false});
|
||||
});
|
||||
|
||||
it('sets focus for screen readers', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
spyOn($.fn, 'focus');
|
||||
topicsView.$(nextPageButtonCss).click();
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
"count": 6,
|
||||
"num_pages": 2,
|
||||
"current_page": 2,
|
||||
"start": 5,
|
||||
"results": generateTopics(1, 1)
|
||||
});
|
||||
expect(topicsView.$('.sr-is-focusable').focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not change on server error', function () {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
expectInitialState = function () {
|
||||
expectHeader('Showing 1-5 out of 6 total');
|
||||
expectTopics(initialTopics);
|
||||
expectFooter({currentPage: 1, totalPages: 2, isHidden: false});
|
||||
};
|
||||
expectInitialState();
|
||||
topicsView.$(nextPageButtonCss).click();
|
||||
requests[0].respond(500);
|
||||
expectInitialState();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
;(function (define) {
|
||||
'use strict';
|
||||
|
||||
define(['jquery','teams/js/views/teams_tab'],
|
||||
function ($, TeamsTabView) {
|
||||
return function () {
|
||||
define(['jquery', 'teams/js/views/teams_tab', 'teams/js/collections/topic'],
|
||||
function ($, TeamsTabView, TopicCollection) {
|
||||
return function (topics, topics_url, course_id) {
|
||||
var topicCollection = new TopicCollection(topics, {url: topics_url, course_id: course_id, parse: true});
|
||||
topicCollection.bootstrap();
|
||||
var view = new TeamsTabView({
|
||||
el: $('.teams-content')
|
||||
el: $('.teams-content'),
|
||||
topicCollection: topicCollection
|
||||
});
|
||||
view.render();
|
||||
};
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
'gettext',
|
||||
'js/components/header/views/header',
|
||||
'js/components/header/models/header',
|
||||
'js/components/tabbed/views/tabbed_view'],
|
||||
function (Backbone, _, gettext, HeaderView, HeaderModel, TabbedView) {
|
||||
'js/components/tabbed/views/tabbed_view',
|
||||
'teams/js/views/topics'],
|
||||
function (Backbone, _, gettext, HeaderView, HeaderModel, TabbedView, TopicsView) {
|
||||
var TeamTabView = Backbone.View.extend({
|
||||
initialize: function() {
|
||||
initialize: function(options) {
|
||||
this.headerModel = new HeaderModel({
|
||||
description: gettext("Course teams are organized into topics created by course instructors. Try to join others in an existing team before you decide to create a new team!"),
|
||||
title: gettext("Teams")
|
||||
@@ -24,7 +25,7 @@
|
||||
},
|
||||
|
||||
render: function () {
|
||||
this.$el.text(this.text)
|
||||
this.$el.text(this.text);
|
||||
}
|
||||
});
|
||||
this.tabbedView = new TabbedView({
|
||||
@@ -35,7 +36,9 @@
|
||||
}, {
|
||||
title: gettext('Browse'),
|
||||
url: 'browse',
|
||||
view: new TempTabView({text: 'Browse team topics here.'})
|
||||
view: new TopicsView({
|
||||
collection: options.topicCollection
|
||||
})
|
||||
}]
|
||||
});
|
||||
Backbone.history.start();
|
||||
|
||||
@@ -41,7 +41,13 @@
|
||||
description: function () { return this.model.get('description'); },
|
||||
details: function () { return this.detailViews; },
|
||||
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;
|
||||
|
||||
54
lms/djangoapps/teams/static/teams/js/views/topics.js
Normal file
54
lms/djangoapps/teams/static/teams/js/views/topics.js
Normal file
@@ -0,0 +1,54 @@
|
||||
;(function (define) {
|
||||
'use strict';
|
||||
define([
|
||||
'backbone',
|
||||
'underscore',
|
||||
'gettext',
|
||||
'common/js/components/views/list',
|
||||
'common/js/components/views/paging_header',
|
||||
'common/js/components/views/paging_footer',
|
||||
'teams/js/views/topic_card',
|
||||
'text!teams/templates/topics.underscore'
|
||||
], function (Backbone, _, gettext, ListView, PagingHeader, PagingFooterView, TopicCardView, topics_template) {
|
||||
var TopicsListView = ListView.extend({
|
||||
tagName: 'div',
|
||||
className: 'topics-container',
|
||||
itemViewClass: TopicCardView
|
||||
});
|
||||
|
||||
var TopicsView = Backbone.View.extend({
|
||||
initialize: function() {
|
||||
this.listView = new TopicsListView({collection: this.collection});
|
||||
this.headerView = new PagingHeader({collection: this.collection});
|
||||
this.pagingFooterView = new PagingFooterView({
|
||||
collection: this.collection, hideWhenOnePage: true
|
||||
});
|
||||
// Focus top of view for screen readers
|
||||
this.collection.on('page_changed', function () {
|
||||
this.$('.sr-is-focusable.sr-topics-view').focus();
|
||||
}, this);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
this.$el.html(_.template(topics_template));
|
||||
this.assign(this.listView, '.topics-list');
|
||||
this.assign(this.headerView, '.topics-paging-header');
|
||||
this.assign(this.pagingFooterView, '.topics-paging-footer');
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper method to render subviews and re-bind events.
|
||||
*
|
||||
* Borrowed from http://ianstormtaylor.com/rendering-views-in-backbonejs-isnt-always-simple/
|
||||
*
|
||||
* @param view The Backbone view to render
|
||||
* @param selector The string CSS selector which the view should attach to
|
||||
*/
|
||||
assign: function(view, selector) {
|
||||
view.setElement(this.$(selector)).render();
|
||||
}
|
||||
});
|
||||
return TopicsView;
|
||||
});
|
||||
}).call(this, define || RequireJS.define);
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="sr-is-focusable sr-topics-view" tabindex="-1"></div>
|
||||
<div class="topics-paging-header"></div>
|
||||
<div class="topics-list"></div>
|
||||
<div class="topics-paging-footer"></div>
|
||||
@@ -1,5 +1,7 @@
|
||||
## mako
|
||||
<%! import json %>
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from openedx.core.lib.json_utils import EscapedEdxJSONEncoder %>
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
<%inherit file="/main.html" />
|
||||
|
||||
@@ -22,7 +24,7 @@
|
||||
<script type="text/javascript">
|
||||
(function (require) {
|
||||
require(['teams/js/teams_tab_factory'], function (TeamsTabFactory) {
|
||||
var pageView = new TeamsTabFactory();
|
||||
new TeamsTabFactory(${ json.dumps(topics, cls=EscapedEdxJSONEncoder) }, '${ topics_url }', '${ unicode(course.id) }');
|
||||
});
|
||||
}).call(this, require || RequireJS.require);
|
||||
</script>
|
||||
|
||||
@@ -531,18 +531,19 @@ class TestListTopicsAPI(TeamAPITestCase):
|
||||
self.get_topics_list(400)
|
||||
|
||||
@ddt.data(
|
||||
(None, 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power']),
|
||||
('name', 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power']),
|
||||
('no_such_field', 400, []),
|
||||
(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'),
|
||||
('no_such_field', 400, [], None),
|
||||
)
|
||||
@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}
|
||||
if field:
|
||||
data['order_by'] = field
|
||||
topics = self.get_topics_list(status, data)
|
||||
if status == 200:
|
||||
self.assertEqual(names, [topic['name'] for topic in topics['results']])
|
||||
self.assertEqual(topics['sort_order'], expected_ordering)
|
||||
|
||||
def test_pagination(self):
|
||||
response = self.get_topics_list(data={
|
||||
@@ -556,6 +557,10 @@ class TestListTopicsAPI(TeamAPITestCase):
|
||||
self.assertIsNone(response['previous'])
|
||||
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
|
||||
class TestDetailTopicAPI(TeamAPITestCase):
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""HTTP endpoints for the Teams API."""
|
||||
|
||||
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 django.http import Http404
|
||||
from django.conf import settings
|
||||
from django.core.paginator import Paginator
|
||||
from django.views.generic.base import View
|
||||
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.authentication import (
|
||||
SessionAuthentication,
|
||||
@@ -45,6 +46,10 @@ from .serializers import CourseTeamSerializer, CourseTeamCreationSerializer, Top
|
||||
from .errors import AlreadyOnTeamInCourse, NotEnrolledInCourseForTeam
|
||||
|
||||
|
||||
# Constants
|
||||
TOPICS_PER_PAGE = 12
|
||||
|
||||
|
||||
class TeamsDashboardView(View):
|
||||
"""
|
||||
View methods related to the teams dashboard.
|
||||
@@ -67,7 +72,13 @@ class TeamsDashboardView(View):
|
||||
not has_access(request.user, 'staff', course, course.id):
|
||||
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)
|
||||
|
||||
|
||||
@@ -479,7 +490,7 @@ class TopicListView(GenericAPIView):
|
||||
authentication_classes = (OAuth2Authentication, SessionAuthentication)
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
paginate_by = 10
|
||||
paginate_by = TOPICS_PER_PAGE
|
||||
paginate_by_param = 'page_size'
|
||||
pagination_serializer_class = PaginationSerializer
|
||||
serializer_class = TopicSerializer
|
||||
@@ -510,11 +521,9 @@ class TopicListView(GenericAPIView):
|
||||
if not has_team_api_access(request.user, course_id):
|
||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
topics = course_module.teams_topics
|
||||
|
||||
ordering = request.QUERY_PARAMS.get('order_by', 'name')
|
||||
if ordering == 'name':
|
||||
topics = sorted(topics, key=lambda t: t['name'].lower())
|
||||
topics = get_ordered_topics(course_module, ordering)
|
||||
else:
|
||||
return Response({
|
||||
'developer_message': "unsupported order_by value {}".format(ordering),
|
||||
@@ -523,9 +532,23 @@ class TopicListView(GenericAPIView):
|
||||
|
||||
page = self.paginate_queryset(topics)
|
||||
serializer = self.get_pagination_serializer(page)
|
||||
serializer.context = {'sort_order': ordering}
|
||||
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):
|
||||
"""
|
||||
**Use Cases**
|
||||
|
||||
@@ -1634,7 +1634,6 @@ STATICFILES_IGNORE_PATTERNS = (
|
||||
|
||||
# Symlinks used by js-test-tool
|
||||
"xmodule_js",
|
||||
"common",
|
||||
)
|
||||
|
||||
PIPELINE_UGLIFYJS_BINARY = 'node_modules/.bin/uglifyjs'
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* "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.
|
||||
* - 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.
|
||||
* - 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"
|
||||
@@ -17,10 +18,10 @@
|
||||
;(function (define) {
|
||||
'use strict';
|
||||
define(['jquery',
|
||||
'underscore',
|
||||
'backbone',
|
||||
'text!templates/components/card/square_card.underscore',
|
||||
'text!templates/components/card/list_card.underscore'],
|
||||
function ($, Backbone, squareCardTemplate, listCardTemplate) {
|
||||
'text!templates/components/card/card.underscore'],
|
||||
function ($, _, Backbone, cardTemplate) {
|
||||
var CardView = Backbone.View.extend({
|
||||
events: {
|
||||
'click .action' : 'action'
|
||||
@@ -40,13 +41,11 @@
|
||||
},
|
||||
|
||||
initialize: function () {
|
||||
this.template = this.switchOnConfiguration(
|
||||
_.template(squareCardTemplate),
|
||||
_.template(listCardTemplate)
|
||||
);
|
||||
this.render();
|
||||
},
|
||||
|
||||
template: _.template(cardTemplate),
|
||||
|
||||
switchOnConfiguration: function (square_result, list_result) {
|
||||
return this.callIfFunction(this.configuration) === 'square_card' ?
|
||||
square_result : list_result;
|
||||
@@ -61,20 +60,30 @@
|
||||
},
|
||||
|
||||
className: function () {
|
||||
return 'card ' +
|
||||
this.switchOnConfiguration('square-card', 'list-card') +
|
||||
' ' + this.callIfFunction(this.cardClass);
|
||||
var result = 'card ' +
|
||||
this.switchOnConfiguration('square-card', 'list-card') + ' ' +
|
||||
this.callIfFunction(this.cardClass);
|
||||
if (this.callIfFunction(this.pennant)) {
|
||||
result += ' has-pennant';
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
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({
|
||||
pennant: this.callIfFunction(this.pennant),
|
||||
title: this.callIfFunction(this.title),
|
||||
description: this.callIfFunction(this.description),
|
||||
description: description,
|
||||
action_class: this.callIfFunction(this.actionClass),
|
||||
action_url: this.callIfFunction(this.actionUrl),
|
||||
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) {
|
||||
// Call setElement to rebind event handlers
|
||||
detail.setElement(detail.el).render();
|
||||
@@ -86,6 +95,7 @@
|
||||
|
||||
action: function () { },
|
||||
cardClass: '',
|
||||
pennant: '',
|
||||
title: '',
|
||||
description: '',
|
||||
details: [],
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
function (Backbone, _, $, tabbedViewTemplate, tabTemplate) {
|
||||
var TabbedView = Backbone.View.extend({
|
||||
events: {
|
||||
'click .nav-item': 'switchTab'
|
||||
'click .nav-item[role="tab"]': 'switchTab'
|
||||
},
|
||||
|
||||
template: _.template(tabbedViewTemplate),
|
||||
@@ -45,9 +45,8 @@
|
||||
view = tab.view;
|
||||
this.$('a.is-active').removeClass('is-active').attr('aria-selected', 'false');
|
||||
this.$('a[data-index='+index+']').addClass('is-active').attr('aria-selected', 'true');
|
||||
view.render();
|
||||
this.$('.page-content-main').html(view.$el.html());
|
||||
this.$('.sr-is-focusable').focus();
|
||||
view.setElement(this.$('.page-content-main')).render();
|
||||
this.$('.sr-is-focusable.sr-tab').focus();
|
||||
this.router.navigate(tab.url, {replace: true});
|
||||
},
|
||||
|
||||
|
||||
@@ -12,13 +12,20 @@
|
||||
it('can render itself as a square card', function () {
|
||||
var view = new CardView({ configuration: '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 () {
|
||||
var view = new CardView({ configuration: '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 () {
|
||||
@@ -38,6 +45,7 @@
|
||||
|
||||
var verifyContent = function (view) {
|
||||
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-description').text()).toContain('A test description');
|
||||
expect(view.$el.find('.action')).toHaveClass('test-action');
|
||||
@@ -45,9 +53,10 @@
|
||||
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({
|
||||
cardClass: 'test-card',
|
||||
pennant: 'Pennant',
|
||||
title: 'A test title',
|
||||
description: 'A test description',
|
||||
actionClass: 'test-action',
|
||||
@@ -57,9 +66,10 @@
|
||||
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({
|
||||
cardClass: function () { return 'test-card'; },
|
||||
pennant: function () { return 'Pennant'; },
|
||||
title: function () { return 'A test title'; },
|
||||
description: function () { return 'A test description'; },
|
||||
actionClass: function () { return 'test-action'; },
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
});
|
||||
|
||||
it('can render itself', function () {
|
||||
expect(view.$el.html()).toContain('<nav class="page-content-nav" role="tablist">')
|
||||
expect(view.$el.html()).toContain('<nav class="page-content-nav"');
|
||||
});
|
||||
|
||||
it('shows its first tab by default', function () {
|
||||
@@ -77,6 +77,12 @@
|
||||
view.$('.nav-item[data-index=1]').click();
|
||||
expect(Backbone.history.navigate).toHaveBeenCalledWith('test 2', {replace: true});
|
||||
});
|
||||
|
||||
it('sets focus for screen readers', function () {
|
||||
spyOn($.fn, 'focus');
|
||||
view.$('.nav-item[data-index=1]').click();
|
||||
expect(view.$('.sr-is-focusable.sr-tab').focus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
'backbone': 'xmodule_js/common_static/js/vendor/backbone-min',
|
||||
'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-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",
|
||||
'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',
|
||||
@@ -613,6 +614,8 @@
|
||||
// Run the LMS tests
|
||||
'lms/include/teams/js/spec/teams_factory_spec.js',
|
||||
'lms/include/teams/js/spec/topic_card_spec.js',
|
||||
'lms/include/teams/js/spec/topic_collection_spec.js',
|
||||
'lms/include/teams/js/spec/topics_spec.js',
|
||||
'lms/include/js/spec/components/header/header_spec.js',
|
||||
'lms/include/js/spec/components/tabbed/tabbed_view_spec.js',
|
||||
'lms/include/js/spec/components/card/card_spec.js',
|
||||
|
||||
@@ -57,6 +57,7 @@ lib_paths:
|
||||
- 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/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/test/i18n.js
|
||||
- xmodule_js/common_static/js/vendor/date.js
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"text": 'js/vendor/requirejs/text',
|
||||
"backbone": "js/vendor/backbone-min",
|
||||
"backbone-super": "js/vendor/backbone-super",
|
||||
"backbone.paginator": "js/vendor/backbone.paginator.min",
|
||||
"underscore.string": "js/vendor/underscore.string.min",
|
||||
// Files needed by OVA
|
||||
"annotator": "js/vendor/ova/annotator-full",
|
||||
@@ -89,6 +90,10 @@
|
||||
deps: ["underscore", "jquery"],
|
||||
exports: "Backbone"
|
||||
},
|
||||
"backbone.paginator": {
|
||||
deps: ["backbone"],
|
||||
exports: "Backbone.Paginator"
|
||||
},
|
||||
"backbone-super": {
|
||||
deps: ["backbone"]
|
||||
},
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
.payment-button {
|
||||
float: right;
|
||||
@include margin-left( ($baseline/2) );
|
||||
|
||||
|
||||
&.is-selected {
|
||||
background: $m-green-s1 !important;
|
||||
}
|
||||
@@ -79,4 +79,145 @@
|
||||
.global-new, #global-navigation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Copied from _pagination.scss in cms
|
||||
.pagination {
|
||||
@include clearfix();
|
||||
display: inline-block;
|
||||
width: flex-grid(3, 12);
|
||||
|
||||
&.pagination-compact {
|
||||
@include text-align(right);
|
||||
}
|
||||
|
||||
&.pagination-full {
|
||||
display: block;
|
||||
width: flex-grid(4, 12);
|
||||
margin: $baseline auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
display: block;
|
||||
border: 0;
|
||||
background-image: none;
|
||||
background-color: transparent;
|
||||
padding: ($baseline/2) ($baseline*0.75);
|
||||
|
||||
&.previous {
|
||||
margin-right: ($baseline/2);
|
||||
}
|
||||
|
||||
&.next {
|
||||
margin-left: ($baseline/2);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $blue;
|
||||
border-radius: 3px;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
background-color: transparent;
|
||||
color: $gray-l2;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
/* This wasn't working for me, so I directly copied the rule
|
||||
@extend %cont-text-sr; */
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.pagination-form,
|
||||
.current-page,
|
||||
.page-divider,
|
||||
.total-pages {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.current-page,
|
||||
.page-number-input,
|
||||
.total-pages {
|
||||
@extend %t-copy-base;
|
||||
@extend %t-strong;
|
||||
width: ($baseline*2.5);
|
||||
vertical-align: middle;
|
||||
margin: 0 ($baseline*0.75);
|
||||
padding: ($baseline/4);
|
||||
text-align: center;
|
||||
color: $gray;
|
||||
}
|
||||
|
||||
.current-page {
|
||||
@extend %ui-depth1;
|
||||
position: absolute;
|
||||
@include left(-($baseline/4));
|
||||
}
|
||||
|
||||
.page-divider {
|
||||
@extend %t-title4;
|
||||
@extend %t-regular;
|
||||
vertical-align: middle;
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.pagination-form {
|
||||
@extend %ui-depth2;
|
||||
position: relative;
|
||||
|
||||
.page-number-label,
|
||||
.submit-pagination-form {
|
||||
/* This wasn't working for me, so I directly copied the rule
|
||||
@extend %cont-text-sr; */
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.page-number-input {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 1px dotted $gray-l2;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: none;
|
||||
|
||||
&:hover {
|
||||
background-color: $white;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
// borrowing the base input focus styles to match overall app
|
||||
@include linear-gradient($paleYellow, tint($paleYellow, 90%));
|
||||
opacity: 1.0;
|
||||
box-shadow: 0 0 3px $shadow-d1 inset;
|
||||
background-color: $white;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<div class="card-core-wrapper">
|
||||
<div class="wrapper-card-core">
|
||||
<div class="card-core">
|
||||
<% if (pennant) { %>
|
||||
<small class="card-type"><%- pennant %></small>
|
||||
<% } %>
|
||||
<h3 class="card-title"><%- title %></h3>
|
||||
<p class="card-description"><%- description %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper-card-meta has-actions">
|
||||
<div class="card-meta">
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<a class="action <%= action_class %>" href="<%= action_url %>"><%= action_content %></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-meta-wrapper">
|
||||
<div class="card-meta-details">
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,13 +0,0 @@
|
||||
<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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="page-content">
|
||||
<nav class="page-content-nav" role="tablist"></nav>
|
||||
<div class="sr-is-focusable" tabindex="-1"></div>
|
||||
<nav class="page-content-nav" aria-label="Teams"></nav>
|
||||
<div class="sr-is-focusable sr-tab" tabindex="-1"></div>
|
||||
<div class="page-content-main"></div>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,30 @@ from rest_framework import pagination, serializers
|
||||
|
||||
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')
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user