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