Commit f9c45586 by Andy Armstrong

Add pagination to Studio's Files and Uploads page

These changes implement STUD-813. The commit consists of the
following logical changes:
 - a REST API has been implemented for a course's assets
 - the page itself now fetches the assets client-side
 - the Backbone.Paginator library is used to support pagination
 - the AssetCollection has been refactored to extend
   Backbone.Paginator.requestPager so that it can be paged
 - an abstract PagingView class has been added to generalize
   the communication with a paging REST API
 - the AssetsView has been reimplemented to extend PagingView
 - two new child views have been added:
   - PagingHeader: the paging controls above the list of assets
   - PagingFooter: the paging controls below the assets
parent 5dccff51
......@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
Studio: Added pagination to the Files & Uploads page.
Blades: Video player improvements:
- Disable edX controls on iPhone/iPod (native controls are used).
- Disable unsupported controls (volume, playback rate) on iPad/Android.
......
......@@ -41,7 +41,7 @@ class AssetsTestCase(CourseTestCase):
class AssetsToyCourseTestCase(CourseTestCase):
"""
Tests the assets returned from assets_handler (full page content) for the toy test course.
Tests the assets returned from assets_handler for the toy test course.
"""
def test_toy_assets(self):
module_store = modulestore('direct')
......@@ -56,10 +56,17 @@ class AssetsToyCourseTestCase(CourseTestCase):
location = loc_mapper().translate_location(course.location.course_id, course.location, False, True)
url = location.url_reverse('assets/', '')
resp = self.client.get(url, HTTP_ACCEPT='text/html')
# Test a small portion of the asset data passed to the client.
self.assertContains(resp, "new AssetCollection([{")
self.assertContains(resp, "/c4x/edX/toy/asset/handouts_sample_handout.txt")
self.assert_correct_asset_response(url, 0, 3, 3)
self.assert_correct_asset_response(url + "?page_size=2", 0, 2, 3)
self.assert_correct_asset_response(url + "?page_size=2&page=1", 2, 1, 3)
def assert_correct_asset_response(self, url, expected_start, expected_length, expected_total):
resp = self.client.get(url, HTTP_ACCEPT='application/json')
json_response = json.loads(resp.content)
assets = json_response['assets']
self.assertEquals(json_response['start'], expected_start)
self.assertEquals(len(assets), expected_length)
self.assertEquals(json_response['totalCount'], expected_total)
class UploadTestCase(CourseTestCase):
......@@ -82,10 +89,6 @@ class UploadTestCase(CourseTestCase):
resp = self.client.post(self.url, {"name": "file.txt"}, "application/json")
self.assertEquals(resp.status_code, 400)
def test_get(self):
with self.assertRaises(NotImplementedError):
self.client.get(self.url)
class AssetToJsonTestCase(TestCase):
"""
......@@ -163,80 +166,3 @@ class LockAssetTestCase(CourseTestCase):
resp_asset = post_asset_update(False)
self.assertFalse(resp_asset['locked'])
verify_asset_locked_state(False)
class TestAssetIndex(CourseTestCase):
"""
Test getting asset lists via http (Note, the assets don't actually exist)
"""
def setUp(self):
"""
Create fake asset entries for the other tests to use
"""
super(TestAssetIndex, self).setUp()
self.entry_filter = self.create_asset_entries(contentstore(), 100)
location = loc_mapper().translate_location(self.course.location.course_id, self.course.location, False, True)
self.url = location.url_reverse('assets/', '')
def tearDown(self):
"""
Get rid of the entries
"""
contentstore().fs_files.remove(self.entry_filter)
def create_asset_entries(self, cstore, number):
"""
Create the fake entries
"""
course_filter = Location(
XASSET_LOCATION_TAG, category='asset', course=self.course.location.course, org=self.course.location.org
)
# purge existing entries (a bit brutal but hopefully tests are independent enuf to not trip on this)
cstore.fs_files.remove(location_to_query(course_filter))
base_entry = {
'displayname': 'foo.jpg',
'chunkSize': 262144,
'length': 0,
'uploadDate': datetime(2012, 1, 2, 0, 0),
'contentType': 'image/jpeg',
}
for i in range(number):
base_entry['displayname'] = '{:03x}.jpeg'.format(i)
base_entry['uploadDate'] += timedelta(hours=i)
base_entry['_id'] = course_filter.replace(name=base_entry['displayname']).dict()
cstore.fs_files.insert(base_entry)
return course_filter.dict()
ASSET_LIST_RE = re.compile(r'AssetCollection\((.*)\);$', re.MULTILINE)
def check_page_content(self, resp_content, entry_count, last_date=None):
"""
:param entry_count:
:param last_date:
"""
match = self.ASSET_LIST_RE.search(resp_content)
asset_list = json.loads(match.group(1))
self.assertEqual(len(asset_list), entry_count)
for row in asset_list:
datetext = row['date_added']
parsed_date = datetime.strptime(datetext, "%b %d, %Y at %H:%M UTC")
if last_date is None:
last_date = parsed_date
else:
self.assertGreaterEqual(last_date, parsed_date)
return last_date
def test_query_assets(self):
"""
The actual test
"""
# get all
resp = self.client.get(self.url, HTTP_ACCEPT='text/html')
self.check_page_content(resp.content, 100)
# get first page of 10
resp = self.client.get(self.url + "?max=10", HTTP_ACCEPT='text/html')
last_date = self.check_page_content(resp.content, 10)
# get next of 20
resp = self.client.get(self.url + "?start=10&max=20", HTTP_ACCEPT='text/html')
self.check_page_content(resp.content, 20, last_date)
......@@ -176,7 +176,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
Lock an arbitrary asset in the course
:param course_location:
"""
course_assets = content_store.get_all_content_for_course(course_location)
course_assets,__ = content_store.get_all_content_for_course(course_location)
self.assertGreater(len(course_assets), 0, "No assets to lock")
content_store.set_attr(course_assets[0]['_id'], 'locked', True)
return course_assets[0]['_id']
......@@ -585,7 +585,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertIsNotNone(course)
# make sure we have some assets in our contentstore
all_assets = content_store.get_all_content_for_course(course_location)
all_assets,__ = content_store.get_all_content_for_course(course_location)
self.assertGreater(len(all_assets), 0)
# make sure we have some thumbnails in our contentstore
......@@ -698,7 +698,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# make sure there's something in the trashcan
course_location = CourseDescriptor.id_to_location('edX/toy/6.002_Spring_2012')
all_assets = trash_store.get_all_content_for_course(course_location)
all_assets,__ = trash_store.get_all_content_for_course(course_location)
self.assertGreater(len(all_assets), 0)
# make sure we have some thumbnails in our trashcan
......@@ -713,8 +713,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
empty_asset_trashcan([course_location])
# make sure trashcan is empty
all_assets = trash_store.get_all_content_for_course(course_location)
all_assets,count = trash_store.get_all_content_for_course(course_location)
self.assertEqual(len(all_assets), 0)
self.assertEqual(count, 0)
all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location)
self.assertEqual(len(all_thumbnails), 0)
......@@ -923,8 +924,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertEqual(len(items), 0)
# assert that all content in the asset library is also deleted
assets = content_store.get_all_content_for_course(location)
assets,count = content_store.get_all_content_for_course(location)
self.assertEqual(len(assets), 0)
self.assertEqual(count, 0)
def verify_content_existence(self, store, root_dir, location, dirname, category_name, filename_suffix=''):
filesystem = OSFS(root_dir / 'test_export')
......
......@@ -84,9 +84,10 @@ class ContentStoreImportNoStaticTest(ModuleStoreTestCase):
_, content_store, course, course_location = self.load_test_import_course()
# make sure we have ONE asset in our contentstore ("should_be_imported.html")
all_assets = content_store.get_all_content_for_course(course_location)
all_assets,count = content_store.get_all_content_for_course(course_location)
print "len(all_assets)=%d" % len(all_assets)
self.assertEqual(len(all_assets), 1)
self.assertEqual(count, 1)
content = None
try:
......@@ -114,9 +115,10 @@ class ContentStoreImportNoStaticTest(ModuleStoreTestCase):
module_store.get_item(course_location)
# make sure we have NO assets in our contentstore
all_assets = content_store.get_all_content_for_course(course_location)
all_assets,count = content_store.get_all_content_for_course(course_location)
print "len(all_assets)=%d" % len(all_assets)
self.assertEqual(len(all_assets), 0)
self.assertEqual(count, 0)
def test_no_static_link_rewrites_on_import(self):
module_store = modulestore('direct')
......
......@@ -41,9 +41,10 @@ def assets_handler(request, tag=None, package_id=None, branch=None, version_guid
deleting assets, and changing the "locked" state of an asset.
GET
html: return html page of all course assets (note though that a range of assets can be requested using start
and max query parameters)
json: not currently supported
html: return html page which will show all course assets. Note that only the asset container
is returned and that the actual assets are filled in with a client-side request.
json: returns a page of assets. A page parameter specifies the desired page, and the
optional page_size parameter indicates the number of items per page (defaults to 50).
POST
json: create (or update?) an asset. The only updating that can be done is changing the lock state.
PUT
......@@ -55,9 +56,10 @@ def assets_handler(request, tag=None, package_id=None, branch=None, version_guid
if not has_access(request.user, location):
raise PermissionDenied()
if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
response_format = request.REQUEST.get('format', 'html')
if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
if request.method == 'GET':
raise NotImplementedError('coming soon')
return _assets_json(request, location)
else:
return _update_asset(request, location, asset_id)
elif request.method == 'GET': # assume html
......@@ -73,22 +75,32 @@ def _asset_index(request, location):
Supports start (0-based index into the list of assets) and max query parameters.
"""
old_location = loc_mapper().translate_locator_to_location(location)
course_module = modulestore().get_item(old_location)
maxresults = request.REQUEST.get('max', None)
start = request.REQUEST.get('start', None)
return render_to_response('asset_index.html', {
'context_course': course_module,
'asset_callback_url': location.url_reverse('assets/', '')
})
def _assets_json(request, location):
"""
Display an editable asset library.
Supports start (0-based index into the list of assets) and max query parameters.
"""
requested_page = int(request.REQUEST.get('page', 0))
requested_page_size = int(request.REQUEST.get('page_size', 50))
current_page = max(requested_page, 0)
start = current_page * requested_page_size
old_location = loc_mapper().translate_locator_to_location(location)
course_reference = StaticContent.compute_location(old_location.org, old_location.course, old_location.name)
if maxresults is not None:
maxresults = int(maxresults)
start = int(start) if start else 0
assets = contentstore().get_all_content_for_course(
course_reference, start=start, maxresults=maxresults,
sort=[('uploadDate', DESCENDING)]
)
else:
assets = contentstore().get_all_content_for_course(
course_reference, sort=[('uploadDate', DESCENDING)]
)
assets, total_count = contentstore().get_all_content_for_course(
course_reference, start=start, maxresults=requested_page_size, sort=[('uploadDate', DESCENDING)]
)
end = start + len(assets)
asset_json = []
for asset in assets:
......@@ -101,10 +113,13 @@ def _asset_index(request, location):
asset_locked = asset.get('locked', False)
asset_json.append(_get_asset_json(asset['displayname'], asset['uploadDate'], asset_location, thumbnail_location, asset_locked))
return render_to_response('asset_index.html', {
'context_course': course_module,
'asset_list': json.dumps(asset_json),
'asset_callback_url': location.url_reverse('assets/', '')
return JsonResponse({
'start': start,
'end': end,
'page': current_page,
'pageSize': requested_page_size,
'totalCount': total_count,
'assets': asset_json
})
......
......@@ -24,6 +24,7 @@ requirejs.config({
"underscore.string": "xmodule_js/common_static/js/vendor/underscore.string.min",
"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",
"tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/tiny_mce",
"jquery.tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/jquery.tinymce",
"xmodule": "xmodule_js/src/xmodule",
......@@ -38,6 +39,7 @@ requirejs.config({
"jasmine.async": "xmodule_js/common_static/js/vendor/jasmine.async",
"draggabilly": "xmodule_js/common_static/js/vendor/draggabilly.pkgd",
"domReady": "xmodule_js/common_static/js/vendor/domReady",
"URI": "xmodule_js/common_static/js/vendor/URI.min",
"mathjax": "//edx-static.s3.amazonaws.com/mathjax-MathJax-727332c/MathJax.js?config=TeX-MML-AM_HTMLorMML-full&delayStartupUntil=configured",
"youtube": "//www.youtube.com/player_api?noext",
......@@ -115,6 +117,10 @@ requirejs.config({
deps: ["backbone"],
exports: "Backbone.Associations"
},
"backbone.paginator": {
deps: ["backbone"],
exports: "Backbone.Paginator"
},
"youtube": {
exports: "YT"
},
......@@ -139,6 +145,9 @@ requirejs.config({
]
MathJax.Hub.Configured()
},
"URI": {
exports: "URI"
},
"xmodule": {
exports: "XModule"
},
......@@ -197,10 +206,13 @@ define([
"js/spec/transcripts/videolist_spec", "js/spec/transcripts/message_manager_spec",
"js/spec/transcripts/file_uploader_spec",
"js/spec/utils/module_spec",
"js/spec/models/explicit_url_spec"
"js/spec/views/baseview_spec",
"js/spec/utils/handle_iframe_binding_spec",
"js/spec/utils/module_spec",
"js/spec/views/baseview_spec",
"js/spec/views/paging_spec",
# these tests are run separate in the cms-squire suite, due to process
# isolation issues with Squire.js
......
......@@ -23,6 +23,7 @@ requirejs.config({
"underscore.string": "xmodule_js/common_static/js/vendor/underscore.string.min",
"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",
"tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/tiny_mce",
"jquery.tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/jquery.tinymce",
"xmodule": "xmodule_js/src/xmodule",
......@@ -34,6 +35,7 @@ requirejs.config({
"jasmine.async": "xmodule_js/common_static/js/vendor/jasmine.async",
"draggabilly": "xmodule_js/common_static/js/vendor/draggabilly.pkgd",
"domReady": "xmodule_js/common_static/js/vendor/domReady",
"URI": "xmodule_js/common_static/js/vendor/URI.min",
"mathjax": "//edx-static.s3.amazonaws.com/mathjax-MathJax-727332c/MathJax.js?config=TeX-MML-AM_HTMLorMML-full&delayStartupUntil=configured",
"youtube": "//www.youtube.com/player_api?noext",
......@@ -106,6 +108,10 @@ requirejs.config({
deps: ["backbone"],
exports: "Backbone.Associations"
},
"backbone.paginator": {
deps: ["backbone"],
exports: "Backbone.Paginator"
},
"youtube": {
exports: "YT"
},
......@@ -130,6 +136,9 @@ requirejs.config({
]
MathJax.Hub.Configured();
},
"URI": {
exports: "URI"
},
"xmodule": {
exports: "XModule"
},
......@@ -166,4 +175,3 @@ jasmine.getFixtures().fixturesPath += 'coffee/fixtures'
define([
"coffee/spec/views/assets_spec"
])
......@@ -2,7 +2,10 @@ define ["jasmine", "js/spec/create_sinon", "squire"],
(jasmine, create_sinon, Squire) ->
feedbackTpl = readFixtures('system-feedback.underscore')
assetLibraryTpl = readFixtures('asset-library.underscore')
assetTpl = readFixtures('asset.underscore')
pagingHeaderTpl = readFixtures('paging-header.underscore')
pagingFooterTpl = readFixtures('paging-footer.underscore')
describe "Asset view", ->
beforeEach ->
......@@ -44,7 +47,7 @@ define ["jasmine", "js/spec/create_sinon", "squire"],
spyOn(@model, "save").andCallThrough()
@collection = new AssetCollection([@model])
@collection.url = "update-asset-url"
@collection.url = "assets-url"
@view = new AssetView({model: @model})
waitsFor (=> @view), "AssetView was not created", 1000
......@@ -131,7 +134,10 @@ define ["jasmine", "js/spec/create_sinon", "squire"],
describe "Assets view", ->
beforeEach ->
setFixtures($("<script>", {id: "asset-tpl", type: "text/template"}).text(assetTpl))
setFixtures($("<script>", {id: "asset-library-tpl", type: "text/template"}).text(assetLibraryTpl))
appendSetFixtures($("<script>", {id: "asset-tpl", type: "text/template"}).text(assetTpl))
appendSetFixtures($("<script>", {id: "paging-header-tpl", type: "text/template"}).text(pagingHeaderTpl))
appendSetFixtures($("<script>", {id: "paging-footer-tpl", type: "text/template"}).text(pagingFooterTpl))
window.analytics = jasmine.createSpyObj('analytics', ['track'])
window.course_location_analytics = jasmine.createSpy()
appendSetFixtures(sandbox({id: "asset_table_body"}))
......@@ -145,31 +151,43 @@ define ["jasmine", "js/spec/create_sinon", "squire"],
"Warning": @promptSpies.constructor
})
@mockAsset1 = {
display_name: "test asset 1"
url: 'actual_asset_url_1'
portable_url: 'portable_url_1'
date_added: 'date_1'
thumbnail: null
id: 'id_1'
}
@mockAsset2 = {
display_name: "test asset 2"
url: 'actual_asset_url_2'
portable_url: 'portable_url_2'
date_added: 'date_2'
thumbnail: null
id: 'id_2'
}
@mockAssetsResponse = {
assets: [ @mockAsset1, @mockAsset2 ],
start: 0,
end: 1,
page: 0,
pageSize: 5,
totalCount: 2
}
runs =>
@injector.require ["js/models/asset", "js/collections/asset", "js/views/assets"],
(AssetModel, AssetCollection, AssetsView) =>
@AssetModel = AssetModel
@collection = new AssetCollection [
display_name: "test asset 1"
url: 'actual_asset_url_1'
portable_url: 'portable_url_1'
date_added: 'date_1'
thumbnail: null
id: 'id_1'
,
display_name: "test asset 2"
url: 'actual_asset_url_2'
portable_url: 'portable_url_2'
date_added: 'date_2'
thumbnail: null
id: 'id_2'
]
@collection.url = "update-asset-url"
@collection = new AssetCollection();
@collection.url = "assets-url"
@view = new AssetsView
collection: @collection
el: $('#asset_table_body')
@view.render()
waitsFor (=> @view), "AssetView was not created", 1000
waitsFor (=> @view), "AssetsView was not created", 1000
$.ajax()
......@@ -181,33 +199,38 @@ define ["jasmine", "js/spec/create_sinon", "squire"],
@injector.remove()
describe "Basic", ->
# Separate setup method to work-around mis-parenting of beforeEach methods
setup = (response) ->
requests = create_sinon.requests(this)
@view.setPage(0)
create_sinon.respondWithJson(requests, response)
return requests
it "should render both assets", ->
@view.render()
requests = setup.call(this, @mockAssetsResponse)
expect(@view.$el).toContainText("test asset 1")
expect(@view.$el).toContainText("test asset 2")
it "should remove the deleted asset from the view", ->
requests = create_sinon["requests"](this)
requests = setup.call(this, @mockAssetsResponse)
# Delete the 2nd asset with success from server.
@view.render().$(".remove-asset-button")[1].click()
@view.$(".remove-asset-button")[1].click()
@promptSpies.constructor.mostRecentCall.args[0].actions.primary.click(@promptSpies)
req.respond(200) for req in requests
expect(@view.$el).toContainText("test asset 1")
expect(@view.$el).not.toContainText("test asset 2")
it "does not remove asset if deletion failed", ->
requests = create_sinon["requests"](this)
requests = setup.call(this, @mockAssetsResponse)
# Delete the 2nd asset, but mimic a failure from the server.
@view.render().$(".remove-asset-button")[1].click()
@view.$(".remove-asset-button")[1].click()
@promptSpies.constructor.mostRecentCall.args[0].actions.primary.click(@promptSpies)
req.respond(404) for req in requests
expect(@view.$el).toContainText("test asset 1")
expect(@view.$el).toContainText("test asset 2")
it "adds an asset if asset does not already exist", ->
@view.render()
requests = setup.call(this, @mockAssetsResponse)
model = new @AssetModel
display_name: "new asset"
url: 'new_actual_asset_url'
......@@ -216,12 +239,29 @@ define ["jasmine", "js/spec/create_sinon", "squire"],
thumbnail: null
id: 'idx'
@view.addAsset(model)
create_sinon.respondWithJson(requests,
{
assets: [ @mockAsset1, @mockAsset2,
{
display_name: "new asset"
url: 'new_actual_asset_url'
portable_url: 'portable_url'
date_added: 'date'
thumbnail: null
id: 'idx'
}
],
start: 0,
end: 2,
page: 0,
pageSize: 5,
totalCount: 3
})
expect(@view.$el).toContainText("new asset")
expect(@collection.models.indexOf(model)).toBe(0)
expect(@collection.models.length).toBe(3)
it "does not add an asset if asset already exists", ->
@view.render()
setup.call(this, @mockAssetsResponse)
spyOn(@collection, "add").andCallThrough()
model = @collection.models[1]
@view.addAsset(model)
......
define(["backbone", "js/models/asset"], function(Backbone, AssetModel){
var AssetCollection = Backbone.Collection.extend({
model : AssetModel
});
return AssetCollection;
define(["backbone.paginator", "js/models/asset"], function(BackbonePaginator, AssetModel) {
var AssetCollection = BackbonePaginator.requestPager.extend({
model : AssetModel,
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; },
'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.assets;
}
});
return AssetCollection;
});
......@@ -33,7 +33,7 @@ define(["sinon"], function(sinon) {
var requests = [];
var xhr = sinon.useFakeXMLHttpRequest();
xhr.onCreate = function(request) {
requests.push(request)
requests.push(request);
};
that.after(function() {
......@@ -43,8 +43,16 @@ define(["sinon"], function(sinon) {
return requests;
};
var respondWithJson = function(requests, jsonResponse, requestIndex) {
requestIndex = requestIndex || requests.length - 1;
requests[requestIndex].respond(200,
{ "Content-Type": "application/json" },
JSON.stringify(jsonResponse));
};
return {
"server": fakeServer,
"requests": fakeRequests
"requests": fakeRequests,
"respondWithJson": respondWithJson
};
});
define(["js/views/baseview", "js/views/asset"], function(BaseView, AssetView) {
define(["js/views/paging", "js/views/asset", "js/views/paging_header", "js/views/paging_footer"],
function(PagingView, AssetView, PagingHeader, PagingFooter) {
var AssetsView = BaseView.extend({
var AssetsView = PagingView.extend({
// takes AssetCollection as model
initialize : function() {
this.listenTo(this.collection, 'destroy', this.handleDestroy);
this.render();
PagingView.prototype.initialize.call(this);
var collection = this.collection;
this.template = _.template($("#asset-library-tpl").text());
this.listenTo(collection, 'destroy', this.handleDestroy);
},
render: function() {
this.$el.empty();
this.$el.html(this.template());
this.tableBody = this.$('#asset-table-body');
this.pagingHeader = new PagingHeader({view: this, el: $('#asset-paging-header')});
this.pagingFooter = new PagingFooter({view: this, el: $('#asset-paging-footer')});
this.pagingHeader.render();
this.pagingFooter.render();
var self = this;
this.collection.each(
function(asset) {
var view = new AssetView({model: asset});
self.$el.append(view.render().el);
});
// Hide the contents until the collection has loaded the first time
this.$('.asset-library').hide();
this.$('.no-asset-content').hide();
return this;
},
handleDestroy: function(model, collection, options) {
var index = options.index;
this.$el.children().eq(index).remove();
renderPageItems: function() {
var self = this,
assets = this.collection,
hasAssets = assets.length > 0;
self.tableBody.empty();
if (hasAssets) {
assets.each(
function(asset) {
var view = new AssetView({model: asset});
self.tableBody.append(view.render().el);
});
}
self.$('.asset-library').toggle(hasAssets);
self.$('.no-asset-content').toggle(!hasAssets);
return this;
},
handleDestroy: function(model, collection, options) {
this.collection.fetch({reset: true}); // reload the collection to get a fresh page full of items
analytics.track('Deleted Asset', {
'course': course_location_analytics,
'id': model.get('url')
......@@ -32,17 +52,12 @@ var AssetsView = BaseView.extend({
},
addAsset: function (model) {
// If asset is not already being shown, add it.
if (this.collection.findWhere({'url': model.get('url')}) === undefined) {
this.collection.add(model, {at: 0});
var view = new AssetView({model: model});
this.$el.prepend(view.render().el);
analytics.track('Uploaded a File', {
'course': course_location_analytics,
'asset_url': model.get('url')
});
}
this.setPage(0);
analytics.track('Uploaded a File', {
'course': course_location_analytics,
'asset_url': model.get('url')
});
}
});
......
define(["backbone", "js/views/feedback_alert", "gettext"], function(Backbone, AlertView, gettext) {
var PagingView = Backbone.View.extend({
// takes a Backbone Paginator as a model
initialize: function() {
Backbone.View.prototype.initialize.call(this);
var collection = this.collection;
collection.bind('add', _.bind(this.renderPageItems, this));
collection.bind('remove', _.bind(this.renderPageItems, this));
collection.bind('reset', _.bind(this.renderPageItems, this));
},
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, response, options) {
collection.currentPage = oldPage;
}
});
},
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 PagingView;
}); // end define();
define(["backbone", "underscore"], function(Backbone, _) {
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;
this.template = _.template($("#paging-footer-tpl").text());
collection.bind('add', _.bind(this.render, this));
collection.bind('remove', _.bind(this.render, 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(this.template({
current_page: collection.currentPage,
total_pages: collection.totalPages
}));
this.$(".previous-page-link").toggleClass("is-disabled", currentPage === 0);
this.$(".next-page-link").toggleClass("is-disabled", currentPage === lastPage);
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 && pageNumber !== currentPage) {
view.setPage(pageNumber - 1);
}
pageInput.val(""); // Clear the value as the label will show beneath it
},
nextPage: function() {
this.view.nextPage();
},
previousPage: function() {
this.view.previousPage();
}
});
return PagingFooter;
}); // end define();
define(["backbone", "underscore", "gettext"], function(Backbone, _, gettext) {
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;
this.template = _.template($("#paging-header-tpl").text());
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(this.template({
messageHtml: messageHtml
}));
this.$(".previous-page-link").toggleClass("is-disabled", currentPage === 0);
this.$(".next-page-link").toggleClass("is-disabled", currentPage === lastPage);
return this;
},
messageHtml: function() {
var view = this.view,
collection = view.collection,
start = collection.start,
count = collection.size(),
end = start + count,
total = collection.totalCount,
fmts = gettext('Showing %(current_span)s%(start)s-%(end)s%(end_span)s out of %(total_span)s%(total)s total%(end_span)s, sorted by %(order_span)s%(sort_order)s%(end_span)s');
return '<p>' + interpolate(fmts, {
start: Math.min(start + 1, end),
end: end,
total: total,
sort_order: gettext('Date Added'),
current_span: '<span class="count-current-shown">',
total_span: '<span class="count-total">',
order_span: '<span class="sort-order">',
end_span: '</span>'
}, true) + "</p>";
},
nextPage: function() {
this.view.nextPage();
},
previousPage: function() {
this.view.previousPage();
}
});
return PagingHeader;
}); // end define();
......@@ -38,6 +38,7 @@ lib_paths:
- 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-associations-min.js
- xmodule_js/common_static/js/vendor/backbone.paginator.min.js
- xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker.js
- xmodule_js/common_static/js/vendor/jquery.leanModal.min.js
- xmodule_js/common_static/js/vendor/jquery.ajaxQueue.js
......@@ -55,6 +56,7 @@ lib_paths:
- xmodule_js/common_static/js/vendor/draggabilly.pkgd.js
- xmodule_js/common_static/js/vendor/date.js
- xmodule_js/common_static/js/vendor/domReady.js
- xmodule_js/common_static/js/vendor/URI.min.js
- xmodule_js/common_static/js/vendor/jquery.smooth-scroll.min.js
- xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js
- xmodule_js/common_static/coffee/src/xblock
......
......@@ -38,6 +38,7 @@ lib_paths:
- 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-associations-min.js
- xmodule_js/common_static/js/vendor/backbone.paginator.min.js
- xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker.js
- xmodule_js/common_static/js/vendor/jquery.leanModal.min.js
- xmodule_js/common_static/js/vendor/jquery.form.js
......@@ -49,6 +50,7 @@ lib_paths:
- xmodule_js/common_static/js/vendor/jasmine-imagediff.js
- xmodule_js/common_static/js/vendor/jasmine.async.js
- xmodule_js/common_static/js/vendor/CodeMirror/codemirror.js
- xmodule_js/common_static/js/vendor/URI.min.js
- xmodule_js/src/xmodule.js
- xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js
- xmodule_js/common_static/js/test/i18n.js
......
......@@ -27,9 +27,167 @@
}
.no-asset-content {
@extend %ui-well;
padding: ($baseline*2);
background-color: $gray-l4;
text-align: center;
color: $gray;
.new-button {
@include font-size(14);
margin-left: $baseline;
[class^="icon-"] {
margin-right: ($baseline/2);
}
}
}
.asset-library {
@include clearfix;
.meta-wrap {
margin-bottom: $baseline;
}
.meta {
@extend %t-copy-sub2;
display: inline-block;
vertical-align: top;
width: flex-grid(9, 12);
color: $gray-l1;
.count-current-shown,
.count-total,
.sort-order {
font-weight: 700;
}
}
.pagination {
@include clearfix;
display: inline-block;
width: flex-grid(3, 12);
&.pagination-compact {
text-align: right;
}
&.pagination-full {
display: block;
width: flex-grid(4, 12);
margin: $baseline auto;
}
.nav-item {
position: relative;
display: inline-block;
}
.nav-link {
@include transition(all $tmg-f2 ease-in-out 0s);
display: block;
padding: ($baseline/4) ($baseline*.75);
&.previous {
margin-right: ($baseline/2);
}
&.next {
margin-left: ($baseline/2);
}
&:hover {
background-color: $blue;
border-radius: 3px;
color: $white;
}
&.is-disabled {
background-color: transparent;
color: $gray-l2;
pointer-events: none;
}
}
.nav-label {
@extend .sr;
}
.pagination-form,
.current-page,
.page-divider,
.total-pages {
display: inline-block;
}
.current-page,
.page-number-input,
.total-pages {
@extend %t-copy-base;
width: ($baseline*2.5);
margin: 0 ($baseline*.75);
padding: ($baseline/4);
text-align: center;
color: $gray;
font-weight: 600;
}
.current-page {
@extend %ui-depth1;
position: absolute;
left: -($baseline/4);
}
.page-divider {
@extend %t-title4;
vertical-align: middle;
color: $gray-l2;
font-weight: 400;
}
.pagination-form {
@extend %ui-depth2;
position: relative;
.page-number-label,
.submit-pagination-form {
@extend .sr;
}
.page-number-input {
@include transition(all $tmg-f2 ease-in-out 0s);
border: 1px solid transparent;
border-bottom: 1px dotted $gray-l2;
border-radius: 0;
box-shadow: none;
background: none;
&:hover {
background-color: $white;
opacity: 0.6;
}
&:focus {
// borrowing the base input focus styles to match overall app
@include linear-gradient($paleYellow, tint($paleYellow, 90%));
opacity: 1.0;
box-shadow: 0 0 3px $shadow-d1 inset;
background-color: $white;
border: 1px solid transparent;
border-radius: 3px;
}
}
}
}
table {
width: 100%;
word-wrap: break-word;
......@@ -41,6 +199,11 @@
vertical-align: middle;
text-align: left;
color: $gray;
.current-sort {
font-weight: 700;
border-bottom: 1px solid $gray-l3;
}
}
td {
......
......@@ -9,21 +9,26 @@
<%namespace name='static' file='static_content.html'/>
<%block name="header_extras">
<script type="text/template" id="asset-tpl">
<%static:include path="js/asset.underscore"/>
</script>
% for template_name in ["asset-library", "asset", "paging-header", "paging-footer"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="jsextra">
<script type="text/javascript">
require(["domReady", "jquery", "gettext", "js/models/asset", "js/collections/asset",
"js/views/assets", "js/views/feedback_prompt",
"js/views/feedback_notification", "js/utils/modal", "jquery.fileupload"],
function(domReady, $, gettext, AssetModel, AssetCollection, AssetsView, PromptView, NotificationView, ModalUtils) {
var assets = new AssetCollection(${asset_list});
"js/views/feedback_notification", "js/views/paging_header", "js/views/paging_footer",
"js/utils/modal", "jquery.fileupload"],
function(domReady, $, gettext, AssetModel, AssetCollection, AssetsView, PromptView, NotificationView,
PagingHeader, PagingFooter, ModalUtils) {
var assets = new AssetCollection();
assets.url = "${asset_callback_url}";
var assetsView = new AssetsView({collection: assets, el: $('#asset_table_body')});
var assetsView = new AssetsView({collection: assets, el: $('#asset-library')});
assetsView.render();
assetsView.setPage(0);
var hideModal = function (e) {
if (e) {
......@@ -142,30 +147,7 @@ require(["domReady", "jquery", "gettext", "js/models/asset", "js/collections/ass
<div class="wrapper-content wrapper">
<section class="content">
<article class="asset-library content-primary" role="main">
<table>
<caption class="sr">${_("List of uploaded files and assets in this course")}</caption>
<colgroup>
<col class="thumb-cols" />
<col class="name-cols" />
<col class="date-cols" />
<col class="embed-cols" />
<col class="actions-cols" />
</colgroup>
<thead>
<tr>
<th class="thumb-col">${_("Preview")}</th>
<th class="name-col">${_("Name")}</th>
<th class="date-col">${_("Date Added")}</th>
<th class="embed-col">${_("URL")}</th>
<th class="actions-col"><span class="sr">${_("Actions")}</span></th>
</tr>
</thead>
<tbody id="asset_table_body" >
</tbody>
</table>
</article>
<article id="asset-library" class="content-primary" role="main"></article>
<aside class="content-supplementary" role="complimentary">
<div class="bit">
......
......@@ -69,6 +69,7 @@
"underscore.string": "js/vendor/underscore.string.min",
"backbone": "js/vendor/backbone-min",
"backbone.associations": "js/vendor/backbone-associations-min",
"backbone.paginator": "js/vendor/backbone.paginator.min",
"tinymce": "js/vendor/tiny_mce/tiny_mce",
"jquery.tinymce": "js/vendor/tiny_mce/jquery.tinymce",
"xmodule": "/xmodule/xmodule",
......@@ -76,6 +77,7 @@
"utility": "js/src/utility",
"accessibility": "js/src/accessibility_tools",
"draggabilly": "js/vendor/draggabilly.pkgd",
"URI": "/js/vendor/URI.min",
// externally hosted files
"tender": "//edxedge.tenderapp.com/tender_widget",
......@@ -163,6 +165,10 @@
deps: ["backbone"],
exports: "Backbone.Associations"
},
"backbone.paginator": {
deps: ["backbone"],
exports: "Backbone.Paginator"
},
"youtube": {
exports: "YT"
},
......@@ -193,6 +199,9 @@
MathJax.Hub.Configured();
}
},
"URI": {
exports: "URI"
},
"xblock/core": {
exports: "XBlock",
deps: ["jquery", "jquery.immediateDescendents"]
......
<div class="asset-library">
<div id="asset-paging-header"></div>
<table>
<caption class="sr"><%= gettext("List of uploaded files and assets in this course") %></caption>
<colgroup>
<col class="thumb-cols" />
<col class="name-cols" />
<col class="date-cols" />
<col class="embed-cols" />
<col class="actions-cols" />
</colgroup>
<thead>
<tr>
<th class="thumb-col"><%= gettext("Preview") %></th>
<th class="name-col"><%= gettext("Name") %></th>
<th class="date-col"><span class="current-sort" href=""><%= gettext("Date Added") %></span></th>
<th class="embed-col"><%= gettext("URL") %></th>
<th class="actions-col"><span class="sr"><%= gettext("Actions") %></span></th>
</tr>
</thead>
<tbody id="asset-table-body" ></tbody>
</table>
<div id="asset-paging-footer"></div>
</div>
<div class="no-asset-content">
<p><%= gettext("You haven't added any assets to this course yet.") %> <a href="#" class="button upload-button new-button"><i class="icon-plus"></i><%= gettext("Upload your first asset") %></a></p>
</div>
<nav class="pagination pagination-full bottom">
<ol>
<li class="nav-item previous"><a class="nav-link previous-page-link" href="#"><i class="icon-angle-left"></i> <span class="nav-label"><%= gettext("Previous") %></span></a></li>
<li class="nav-item page">
<div class="pagination-form">
<label class="page-number-label" for="page-number"><%= gettext("Page number") %></label>
<input id="page-number-input" class="page-number-input" name="page-number" type="text" size="4" />
</div>
<span class="current-page"><%= current_page + 1 %></span>
<span class="page-divider">/</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-angle-right"></i></a></li>
</ol>
</nav>
<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-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-angle-right"></i></a></li>
</ol>
</nav>
</div>
......@@ -177,7 +177,10 @@ class ContentStore(object):
def get_all_content_for_course(self, location, start=0, maxresults=-1, sort=None):
'''
Returns a list of all static assets for a course. The return format is a list of dictionary elements. Example:
Returns a list of static assets for a course, followed by the total number of assets.
By default all assets are returned, but start and maxresults can be provided to limit the query.
The return format is a list of dictionary elements. Example:
[
......
......@@ -128,7 +128,7 @@ class MongoContentStore(ContentStore):
directory as the other policy files.
"""
policy = {}
assets = self.get_all_content_for_course(course_location)
assets,__ = self.get_all_content_for_course(course_location)
for asset in assets:
asset_location = Location(asset['_id'])
......@@ -141,7 +141,7 @@ class MongoContentStore(ContentStore):
json.dump(policy, f)
def get_all_content_thumbnails_for_course(self, location):
return self._get_all_content_for_course(location, get_thumbnails=True)
return self._get_all_content_for_course(location, get_thumbnails=True)[0]
def get_all_content_for_course(self, location, start=0, maxresults=-1, sort=None):
return self._get_all_content_for_course(
......@@ -178,7 +178,8 @@ class MongoContentStore(ContentStore):
)
else:
items = self.fs_files.find(location_to_query(course_filter), sort=sort)
return list(items)
count = items.count()
return list(items), count
def set_attr(self, location, attr, value=True):
"""
......
......@@ -19,7 +19,7 @@ def empty_asset_trashcan(course_locs):
store.delete(id)
# then delete all of the assets
assets = store.get_all_content_for_course(course_loc)
assets,__ = store.get_all_content_for_course(course_loc)
for asset in assets:
asset_loc = Location(asset["_id"])
id = StaticContent.get_id_from_location(asset_loc)
......
......@@ -199,7 +199,7 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele
# now iterate through all of the assets, also updating the thumbnail pointer
assets = contentstore.get_all_content_for_course(source_location)
assets,__ = contentstore.get_all_content_for_course(source_location)
for asset in assets:
asset_loc = Location(asset["_id"])
content = contentstore.find(asset_loc)
......@@ -260,7 +260,7 @@ def delete_course(modulestore, contentstore, source_location, commit=False):
_delete_assets(contentstore, thumbs, commit)
# then delete all of the assets
assets = contentstore.get_all_content_for_course(source_location)
assets,__ = contentstore.get_all_content_for_course(source_location)
_delete_assets(contentstore, assets, commit)
# then delete all course modules
......
......@@ -223,7 +223,7 @@ class TestMongoModuleStore(object):
Test getting, setting, and defaulting the locked attr and arbitrary attrs.
"""
location = Location('i4x', 'edX', 'toy', 'course', '2012_Fall')
course_content = TestMongoModuleStore.content_store.get_all_content_for_course(location)
course_content,__ = TestMongoModuleStore.content_store.get_all_content_for_course(location)
assert len(course_content) > 0
# a bit overkill, could just do for content[0]
for content in course_content:
......
window.gettext = window.ngettext = function(s){return s;};
function interpolate(fmt, obj, named) {
if (named) {
return fmt.replace(/%\(\w+\)s/g, function(match){return String(obj[match.slice(2,-2)])});
} else {
return fmt.replace(/%s/g, function(match){return String(obj.shift())});
}
}
......@@ -18,7 +18,7 @@
-e git+https://github.com/edx/XBlock.git@fa88607#egg=XBlock
-e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail
-e git+https://github.com/edx/diff-cover.git@v0.2.6#egg=diff_cover
-e git+https://github.com/edx/js-test-tool.git@v0.1.4#egg=js_test_tool
-e git+https://github.com/edx/js-test-tool.git@v0.1.5#egg=js_test_tool
-e git+https://github.com/edx/django-waffle.git@823a102e48#egg=django-waffle
-e git+https://github.com/edx/event-tracking.git@f0211d702d#egg=event-tracking
-e git+https://github.com/edx/bok-choy.git@bc6f1adbe439618162079f1004b2b3db3b6f8916#egg=bok_choy
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment