Commit c4004e11 by Daniel Friedman

Merge pull request #8593 from edx/dan-f/topic-list-2

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