From dcc8e95648755285d652536e90a701b2190cd734 Mon Sep 17 00:00:00 2001 From: Martyn James Date: Thu, 23 Oct 2014 15:37:51 -0400 Subject: [PATCH] Implements SOL-20. Filtering for assets table by asset type --- cms/djangoapps/contentstore/views/assets.py | 77 +++- .../contentstore/views/tests/test_assets.py | 97 ++++- cms/envs/common.py | 21 ++ cms/static/js/base.js | 2 +- cms/static/js/collections/asset.js | 2 + cms/static/js/models/asset.js | 6 + .../views/active_video_upload_list_spec.js | 4 +- cms/static/js/spec/views/assets_spec.js | 191 +++++++++- cms/static/js/spec/views/paging_spec.js | 3 + cms/static/js/views/asset.js | 1 + cms/static/js/views/assets.js | 80 +++- cms/static/js/views/paging.js | 61 ++++ cms/static/js/views/paging_header.js | 43 ++- cms/static/sass/views/_assets.scss | 345 ++++++++++++++++++ cms/templates/asset_index.html | 7 +- cms/templates/js/asset-library.underscore | 49 ++- .../js/asset-upload-modal.underscore | 2 +- cms/templates/js/asset.underscore | 28 +- cms/templates/widgets/header.html | 3 +- .../xmodule/xmodule/contentstore/content.py | 2 +- .../lib/xmodule/xmodule/contentstore/mongo.py | 30 +- .../xmodule/modulestore/tests/test_mongo.py | 36 ++ .../acceptance/pages/studio/asset_index.py | 69 ++++ .../tests/studio/test_studio_asset.py | 57 +++ .../tests/studio/test_studio_library.py | 1 + 25 files changed, 1138 insertions(+), 79 deletions(-) create mode 100644 common/test/acceptance/tests/studio/test_studio_asset.py diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index a6b3ef2cd2..4740a93910 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -31,8 +31,9 @@ from xmodule.modulestore.exceptions import ItemNotFoundError __all__ = ['assets_handler'] - # pylint: disable=unused-argument + + @login_required @ensure_csrf_cookie def assets_handler(request, course_key_string=None, asset_key_string=None): @@ -99,6 +100,29 @@ def _assets_json(request, course_key): requested_page = int(request.REQUEST.get('page', 0)) requested_page_size = int(request.REQUEST.get('page_size', 50)) requested_sort = request.REQUEST.get('sort', 'date_added') + requested_filter = request.REQUEST.get('asset_type', '') + requested_file_types = settings.FILES_AND_UPLOAD_TYPE_FILTERS.get( + requested_filter, None) + filter_params = None + if requested_filter: + if requested_filter == 'OTHER': + all_filters = settings.FILES_AND_UPLOAD_TYPE_FILTERS + where = [] + for all_filter in all_filters: + extension_filters = all_filters[all_filter] + where.extend( + ["JSON.stringify(this.contentType).toUpperCase() != JSON.stringify('{}').toUpperCase()".format( + extension_filter) for extension_filter in extension_filters]) + filter_params = { + "$where": ' && '.join(where), + } + else: + where = ["JSON.stringify(this.contentType).toUpperCase() == JSON.stringify('{}').toUpperCase()".format( + req_filter) for req_filter in requested_file_types] + filter_params = { + "$where": ' || '.join(where), + } + sort_direction = DESCENDING if request.REQUEST.get('direction', '').lower() == 'asc': sort_direction = ASCENDING @@ -112,26 +136,42 @@ def _assets_json(request, course_key): current_page = max(requested_page, 0) start = current_page * requested_page_size - assets, total_count = _get_assets_for_page(request, course_key, current_page, requested_page_size, sort) + options = { + 'current_page': current_page, + 'page_size': requested_page_size, + 'sort': sort, + 'filter_params': filter_params + } + assets, total_count = _get_assets_for_page(request, course_key, options) end = start + len(assets) - # If the query is beyond the final page, then re-query the final page so that at least one asset is returned + # If the query is beyond the final page, then re-query the final page so + # that at least one asset is returned if requested_page > 0 and start >= total_count: - current_page = int(math.floor((total_count - 1) / requested_page_size)) + options['current_page'] = current_page = int(math.floor((total_count - 1) / requested_page_size)) start = current_page * requested_page_size - assets, total_count = _get_assets_for_page(request, course_key, current_page, requested_page_size, sort) + assets, total_count = _get_assets_for_page(request, course_key, options) end = start + len(assets) asset_json = [] for asset in assets: asset_location = asset['asset_key'] - # note, due to the schema change we may not have a 'thumbnail_location' in the result set + # note, due to the schema change we may not have a 'thumbnail_location' + # in the result set thumbnail_location = asset.get('thumbnail_location', None) if thumbnail_location: - thumbnail_location = course_key.make_asset_key('thumbnail', thumbnail_location[4]) + thumbnail_location = course_key.make_asset_key( + 'thumbnail', thumbnail_location[4]) asset_locked = asset.get('locked', False) - asset_json.append(_get_asset_json(asset['displayname'], asset['uploadDate'], asset_location, thumbnail_location, asset_locked)) + asset_json.append(_get_asset_json( + asset['displayname'], + asset['contentType'], + asset['uploadDate'], + asset_location, + thumbnail_location, + asset_locked + )) return JsonResponse({ 'start': start, @@ -144,14 +184,18 @@ def _assets_json(request, course_key): }) -def _get_assets_for_page(request, course_key, current_page, page_size, sort): +def _get_assets_for_page(request, course_key, options): """ Returns the list of assets for the specified page and page size. """ + current_page = options['current_page'] + page_size = options['page_size'] + sort = options['sort'] + filter_params = options['filter_params'] if options['filter_params'] else None start = current_page * page_size return contentstore().get_all_content_for_course( - course_key, start=start, maxresults=page_size, sort=sort + course_key, start=start, maxresults=page_size, sort=sort, filter_params=filter_params ) @@ -239,10 +283,16 @@ def _upload_asset(request, course_key): # readback the saved content - we need the database timestamp readback = contentstore().find(content.location) - locked = getattr(content, 'locked', False) response_payload = { - 'asset': _get_asset_json(content.name, readback.last_modified_at, content.location, content.thumbnail_location, locked), + 'asset': _get_asset_json( + content.name, + content.content_type, + readback.last_modified_at, + content.location, + content.thumbnail_location, + locked + ), 'msg': _('Upload completed') } @@ -305,7 +355,7 @@ def _update_asset(request, course_key, asset_key): return JsonResponse(modified_asset, status=201) -def _get_asset_json(display_name, date, location, thumbnail_location, locked): +def _get_asset_json(display_name, content_type, date, location, thumbnail_location, locked): """ Helper method for formatting the asset information to send to client. """ @@ -313,6 +363,7 @@ def _get_asset_json(display_name, date, location, thumbnail_location, locked): external_url = settings.LMS_BASE + asset_url return { 'display_name': display_name, + 'content_type': content_type, 'date_added': get_default_time_display(date), 'url': asset_url, 'external_url': external_url, diff --git a/cms/djangoapps/contentstore/views/tests/test_assets.py b/cms/djangoapps/contentstore/views/tests/test_assets.py index a340a4d24f..b81cd8b92c 100644 --- a/cms/djangoapps/contentstore/views/tests/test_assets.py +++ b/cms/djangoapps/contentstore/views/tests/test_assets.py @@ -34,17 +34,17 @@ class AssetsTestCase(CourseTestCase): super(AssetsTestCase, self).setUp() self.url = reverse_course_url('assets_handler', self.course.id) - def upload_asset(self, name="asset-1"): + def upload_asset(self, name="asset-1", extension=".txt"): """ Post to the asset upload url """ - f = self.get_sample_asset(name) + f = self.get_sample_asset(name, extension) return self.client.post(self.url, {"name": name, "file": f}) - def get_sample_asset(self, name): + def get_sample_asset(self, name, extension=".txt"): """Returns an in-memory file with the given name for testing""" f = BytesIO(name) - f.name = name + ".txt" + f.name = name + extension return f @@ -98,20 +98,60 @@ class PaginationTestCase(AssetsTestCase): self.upload_asset("asset-1") self.upload_asset("asset-2") self.upload_asset("asset-3") + self.upload_asset("asset-4", ".odt") # Verify valid page requests - self.assert_correct_asset_response(self.url, 0, 3, 3) - self.assert_correct_asset_response(self.url + "?page_size=2", 0, 2, 3) - self.assert_correct_asset_response(self.url + "?page_size=2&page=1", 2, 1, 3) + self.assert_correct_asset_response(self.url, 0, 4, 4) + self.assert_correct_asset_response(self.url + "?page_size=2", 0, 2, 4) + self.assert_correct_asset_response( + self.url + "?page_size=2&page=1", 2, 2, 4) self.assert_correct_sort_response(self.url, 'date_added', 'asc') self.assert_correct_sort_response(self.url, 'date_added', 'desc') self.assert_correct_sort_response(self.url, 'display_name', 'asc') self.assert_correct_sort_response(self.url, 'display_name', 'desc') + self.assert_correct_filter_response(self.url, 'asset_type', '') + self.assert_correct_filter_response(self.url, 'asset_type', 'OTHER') + self.assert_correct_filter_response( + self.url, 'asset_type', 'Documents') # Verify querying outside the range of valid pages - self.assert_correct_asset_response(self.url + "?page_size=2&page=-1", 0, 2, 3) - self.assert_correct_asset_response(self.url + "?page_size=2&page=2", 2, 1, 3) - self.assert_correct_asset_response(self.url + "?page_size=3&page=1", 0, 3, 3) + self.assert_correct_asset_response( + self.url + "?page_size=2&page=-1", 0, 2, 4) + self.assert_correct_asset_response( + self.url + "?page_size=2&page=2", 2, 2, 4) + self.assert_correct_asset_response( + self.url + "?page_size=3&page=1", 3, 1, 4) + + @mock.patch('xmodule.contentstore.mongo.MongoContentStore.get_all_content_for_course') + def test_mocked_filtered_response(self, mock_get_all_content_for_course): + """ + Test the ajax asset interfaces + """ + asset_key = self.course.id.make_asset_key( + AssetMetadata.GENERAL_ASSET_TYPE, 'test.jpg') + upload_date = datetime(2015, 1, 12, 10, 30, tzinfo=UTC) + thumbnail_location = [ + 'c4x', 'edX', 'toy', 'thumbnail', 'test_thumb.jpg', None] + + mock_get_all_content_for_course.return_value = [ + [ + { + "asset_key": asset_key, + "displayname": "test.jpg", + "contentType": "image/jpg", + "url": "/c4x/A/CS102/asset/test.jpg", + "uploadDate": upload_date, + "id": "/c4x/A/CS102/asset/test.jpg", + "portable_url": "/static/test.jpg", + "thumbnail": None, + "thumbnail_location": thumbnail_location, + "locked": None + } + ], + 1 + ] + # Verify valid page requests + self.assert_correct_filter_response(self.url, 'asset_type', 'OTHER') def assert_correct_asset_response(self, url, expected_start, expected_length, expected_total): """ @@ -128,7 +168,8 @@ class PaginationTestCase(AssetsTestCase): """ Get from the url w/ a sort option and ensure items honor that sort """ - resp = self.client.get(url + '?sort=' + sort + '&direction=' + direction, HTTP_ACCEPT='application/json') + resp = self.client.get( + url + '?sort=' + sort + '&direction=' + direction, HTTP_ACCEPT='application/json') json_response = json.loads(resp.content) assets_response = json_response['assets'] name1 = assets_response[0][sort] @@ -141,6 +182,29 @@ class PaginationTestCase(AssetsTestCase): self.assertGreaterEqual(name1, name2) self.assertGreaterEqual(name2, name3) + def assert_correct_filter_response(self, url, filter_type, filter_value): + """ + Get from the url w/ a filter option and ensure items honor that filter + """ + requested_file_types = settings.FILES_AND_UPLOAD_TYPE_FILTERS.get( + filter_value, None) + resp = self.client.get( + url + '?' + filter_type + '=' + filter_value, HTTP_ACCEPT='application/json') + json_response = json.loads(resp.content) + assets_response = json_response['assets'] + if filter_value is not '': + content_types = [asset['content_type'].lower() + for asset in assets_response] + if filter_value is 'OTHER': + all_file_type_extensions = [] + for file_type in settings.FILES_AND_UPLOAD_TYPE_FILTERS: + all_file_type_extensions.extend(file_type) + for content_type in content_types: + self.assertNotIn(content_type, all_file_type_extensions) + else: + for content_type in content_types: + self.assertIn(content_type, requested_file_types) + @ddt class UploadTestCase(AssetsTestCase): @@ -229,13 +293,13 @@ class AssetToJsonTestCase(AssetsTestCase): @override_settings(LMS_BASE="lms_base_url") def test_basic(self): upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC) - + content_type = 'image/jpg' course_key = SlashSeparatedCourseKey('org', 'class', 'run') location = course_key.make_asset_key('asset', 'my_file_name.jpg') thumbnail_location = course_key.make_asset_key('thumbnail', 'my_file_name_thumb.jpg') # pylint: disable=protected-access - output = assets._get_asset_json("my_file", upload_date, location, thumbnail_location, True) + output = assets._get_asset_json("my_file", content_type, upload_date, location, thumbnail_location, True) self.assertEquals(output["display_name"], "my_file") self.assertEquals(output["date_added"], "Jun 01, 2013 at 10:30 UTC") @@ -246,7 +310,7 @@ class AssetToJsonTestCase(AssetsTestCase): self.assertEquals(output["id"], unicode(location)) self.assertEquals(output['locked'], True) - output = assets._get_asset_json("name", upload_date, location, None, False) + output = assets._get_asset_json("name", content_type, upload_date, location, None, False) self.assertIsNone(output["thumbnail"]) @@ -267,6 +331,7 @@ class LockAssetTestCase(AssetsTestCase): def post_asset_update(lock, course): """ Helper method for posting asset update. """ + content_type = 'application/txt' upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC) asset_location = course.id.make_asset_key('asset', 'sample_static.txt') url = reverse_course_url('assets_handler', course.id, kwargs={'asset_key_string': unicode(asset_location)}) @@ -274,9 +339,11 @@ class LockAssetTestCase(AssetsTestCase): resp = self.client.post( url, # pylint: disable=protected-access - json.dumps(assets._get_asset_json("sample_static.txt", upload_date, asset_location, None, lock)), + json.dumps(assets._get_asset_json( + "sample_static.txt", content_type, upload_date, asset_location, None, lock)), "application/json" ) + self.assertEqual(resp.status_code, 201) return json.loads(resp.content) diff --git a/cms/envs/common.py b/cms/envs/common.py index 3aea5b983b..249c02989d 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -820,5 +820,26 @@ ADVANCED_PROBLEM_TYPES = [ 'boilerplate_name': None, } ] + #date format the api will be formatting the datetime values API_DATE_FORMAT = '%Y-%m-%d' + +# Files and Uploads type filter values + +FILES_AND_UPLOAD_TYPE_FILTERS = { + "Images": ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/tiff', 'image/tif', 'image/x-icon'], + "Documents": [ + 'application/pdf', + 'text/plain', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', + 'application/vnd.openxmlformats-officedocument.presentationml.template', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', + 'application/msword', + 'application/vnd.ms-excel', + 'application/vnd.ms-powerpoint', + ], +} diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 242b0217cb..f649252505 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -34,7 +34,7 @@ domReady(function() { $('.nav-dd .nav-item .title').removeClass('is-selected'); }); - $('.nav-dd .nav-item').click(function(e) { + $('.nav-dd .nav-item, .filterable-column .nav-item').click(function(e) { $subnav = $(this).find('.wrapper-nav-sub'); $title = $(this).find('.title'); diff --git a/cms/static/js/collections/asset.js b/cms/static/js/collections/asset.js index 3d91a2c191..580659bcd6 100644 --- a/cms/static/js/collections/asset.js +++ b/cms/static/js/collections/asset.js @@ -1,5 +1,6 @@ define(["backbone.paginator", "js/models/asset"], function(BackbonePaginator, AssetModel) { var AssetCollection = BackbonePaginator.requestPager.extend({ + assetType: '', model : AssetModel, paginator_core: { type: 'GET', @@ -17,6 +18,7 @@ define(["backbone.paginator", "js/models/asset"], function(BackbonePaginator, As 'page_size': function() { return this.perPage; }, 'sort': function() { return this.sortField; }, 'direction': function() { return this.sortDirection; }, + 'asset_type': function() { return this.assetType; }, 'format': 'json' }, diff --git a/cms/static/js/models/asset.js b/cms/static/js/models/asset.js index bcf2589a4d..94af156700 100644 --- a/cms/static/js/models/asset.js +++ b/cms/static/js/models/asset.js @@ -5,12 +5,18 @@ define(["backbone"], function(Backbone) { var Asset = Backbone.Model.extend({ defaults: { display_name: "", + content_type: "", thumbnail: "", date_added: "", url: "", external_url: "", portable_url: "", locked: false + }, + get_extension: function(){ + var name_segments = this.get("display_name").split(".").reverse(); + var asset_type = (name_segments.length > 1) ? name_segments[0].toUpperCase() : ""; + return asset_type; } }); return Asset; diff --git a/cms/static/js/spec/views/active_video_upload_list_spec.js b/cms/static/js/spec/views/active_video_upload_list_spec.js index c0ace2e7ba..4c36cd5bc0 100644 --- a/cms/static/js/spec/views/active_video_upload_list_spec.js +++ b/cms/static/js/spec/views/active_video_upload_list_spec.js @@ -35,14 +35,14 @@ define( var makeUploadUrl = function(fileName) { return "http://www.example.com/test_url/" + fileName; - } + }; var getSentRequests = function() { return _.filter( ajaxRequests, function(request) { return request.readyState > 0; } ); - } + }; _.each( [ diff --git a/cms/static/js/spec/views/assets_spec.js b/cms/static/js/spec/views/assets_spec.js index 3e5e10ef62..20991573fe 100644 --- a/cms/static/js/spec/views/assets_spec.js +++ b/cms/static/js/spec/views/assets_spec.js @@ -1,6 +1,6 @@ -define([ "jquery", "js/common_helpers/ajax_helpers", "js/views/asset", "js/views/assets", +define([ "jquery", "js/common_helpers/ajax_helpers", "URI", "js/views/asset", "js/views/assets", "js/models/asset", "js/collections/asset", "js/spec_helpers/view_helpers"], - function ($, AjaxHelpers, AssetView, AssetsView, AssetModel, AssetCollection, ViewHelpers) { + function ($, AjaxHelpers, URI, AssetView, AssetsView, AssetModel, AssetCollection, ViewHelpers) { describe("Assets", function() { var assetsView, mockEmptyAssetsResponse, mockAssetUploadResponse, mockFileUpload, @@ -28,6 +28,7 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "js/views/asset", "js/views collection: collection, el: $('#asset_table_body') }); + assetsView.render(); }); @@ -50,6 +51,68 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "js/views/asset", "js/views totalCount: 0 }; + var mockExampleAssetsResponse = { + sort: "uploadDate", + end: 2, + 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" + }, + { + "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: 2, + start: 0, + page: 0 + }; + + var mockExampleFilteredAssetsResponse = { + 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" + } + ], + pageSize: 1, + totalCount: 1, + start: 0, + page: 0 + }; + mockAssetUploadResponse = { asset: mockAsset, msg: "Upload completed" @@ -59,16 +122,30 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "js/views/asset", "js/views files: [{name: 'largefile', size: 0}] }; - var event = {} + var respondWithMockAssets = function(requests) { + var requestIndex = requests.length - 1; + var request = requests[requestIndex]; + var url = new URI(request.url); + var queryParameters = url.query(true); // Returns an object with each query parameter stored as a value + var asset_type = queryParameters.asset_type; + var response = asset_type !== '' ? mockExampleFilteredAssetsResponse : mockExampleAssetsResponse; + AjaxHelpers.respondWithJson(requests, response, requestIndex); + }; + + var event = {}; event.target = {"value": "dummy.jpg"}; describe("AssetsView", function () { var setup; - setup = function() { - var requests; - requests = AjaxHelpers.requests(this); + setup = function(responseData) { + var requests = AjaxHelpers.requests(this); assetsView.setPage(0); - AjaxHelpers.respondWithJson(requests, mockEmptyAssetsResponse); + if (!responseData){ + AjaxHelpers.respondWithJson(requests, mockEmptyAssetsResponse); + } + else{ + AjaxHelpers.respondWithJson(requests, responseData); + } return requests; }; @@ -170,6 +247,106 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "js/views/asset", "js/views expect(assetsView.largeFileErrorMsg).toBeNull(); }); + it('returns the registered info for a filter column', function () { + assetsView.registerSortableColumn('test-col', 'Test Column', 'testField', 'asc'); + assetsView.registerFilterableColumn('js-asset-type-col', 'Type', 'asset_type'); + var filterInfo = assetsView.filterableColumnInfo('js-asset-type-col'); + expect(filterInfo.displayName).toBe('Type'); + expect(filterInfo.fieldName).toBe('asset_type'); + }); + + it('throws an exception for an unregistered filter column', function () { + expect(function() { + assetsView.filterableColumnInfo('no-such-column'); + }).toThrow(); + }); + + + it('make sure selectFilter sets collection filter if undefined', function () { + expect(assetsView).toBeDefined(); + assetsView.collection.filterField = ''; + assetsView.selectFilter('js-asset-type-col'); + expect(assetsView.collection.filterField).toEqual('asset_type'); + }); + + it('make sure _toggleFilterColumn filters asset list', function () { + expect(assetsView).toBeDefined(); + var requests = AjaxHelpers.requests(this); + $.each(assetsView.filterableColumns, function(columnID, columnData){ + var $typeColumn = $('#' + columnID); + assetsView.setPage(0); + respondWithMockAssets(requests); + var assetsNumber = assetsView.collection.length; + assetsView._toggleFilterColumn('Images', 'Images'); + respondWithMockAssets(requests); + var assetsNumberFiltered = assetsView.collection.length; + expect(assetsNumberFiltered).toBeLessThan(assetsNumber); + expect($typeColumn.find('.title .type-filter')).not.toEqual(assetsView.allLabel); + }); + }); + + it('opens and closes select type menu', function () { + expect(assetsView).toBeDefined(); + setup.call(this, mockExampleAssetsResponse); + $.each(assetsView.filterableColumns, function(columnID, columnData){ + var $typeColumn = $('#' + columnID); + expect($typeColumn).toBeVisible(); + var assetsNumber = $('#asset-table-body .type-col').length; + assetsView.openFilterColumn($typeColumn); + expect($typeColumn.find('.wrapper-nav-sub')).toHaveClass('is-shown'); + expect($typeColumn.find('.title')).toHaveClass('is-selected'); + expect($typeColumn.find('.column-filter-link')).toBeVisible(); + $typeColumn.find('.wrapper-nav-sub').trigger('click'); + expect($typeColumn.find('.wrapper-nav-sub').hasClass('is-shown')).toBe(false); + }); + }); + + it('check filtering works with sorting by column on', function () { + expect(assetsView).toBeDefined(); + var requests = AjaxHelpers.requests(this); + assetsView.registerSortableColumn('name-col', 'Name Column', 'nameField', 'asc'); + assetsView.registerFilterableColumn('js-asset-type-col', gettext('Type'), 'asset_type'); + assetsView.setInitialSortColumn('name-col'); + assetsView.setPage(0); + respondWithMockAssets(requests); + var sortInfo = assetsView.sortableColumnInfo('name-col'); + expect(sortInfo.defaultSortDirection).toBe('asc'); + var $firstFilter = $($('#js-asset-type-col').find('li.nav-item a')[1]); + $firstFilter.trigger('click'); + respondWithMockAssets(requests); + var assetsNumberFiltered = assetsView.collection.length; + expect(assetsNumberFiltered).toBe(1); + + }); + + it('shows type select menu, selects type, and filters results', function () { + expect(assetsView).toBeDefined(); + var requests = AjaxHelpers.requests(this); + $.each(assetsView.filterableColumns, function(columnID, columnData) { + assetsView.setPage(0); + respondWithMockAssets(requests); + var $typeColumn = $('#' + columnID); + expect($typeColumn).toBeVisible(); + var assetsNumber = assetsView.collection.length; + $typeColumn.trigger('click'); + expect($typeColumn.find('.wrapper-nav-sub')).toHaveClass('is-shown'); + expect($typeColumn.find('.title')).toHaveClass('is-selected'); + var $allFilter = $($typeColumn.find('li.nav-item a')[0]); + var $firstFilter = $($typeColumn.find('li.nav-item a')[1]); + var $otherFilter = $($typeColumn.find('li.nav-item a[data-assetfilter="OTHER"]')[0]); + var select_filter_and_check = function($filterEl, result) { + $filterEl.trigger('click'); + respondWithMockAssets(requests); + var assetsNumberFiltered = assetsView.collection.length; + expect(assetsNumberFiltered).toBe(result); + }; + + select_filter_and_check($firstFilter, 1); + select_filter_and_check($allFilter, assetsNumber); + select_filter_and_check($otherFilter, 1); + }); + }); + it('hides the error modal if a large file, then small file is uploaded', function() { expect(assetsView).toBeDefined(); mockFileUpload.files[0].size = assetsView.maxFileSize; diff --git a/cms/static/js/spec/views/paging_spec.js b/cms/static/js/spec/views/paging_spec.js index 444e87a2ad..e4e87d9698 100644 --- a/cms/static/js/spec/views/paging_spec.js +++ b/cms/static/js/spec/views/paging_spec.js @@ -58,7 +58,9 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "URI", initialize : function() { this.registerSortableColumn('name-col', 'Name', 'name', 'asc'); this.registerSortableColumn('date-col', 'Date', 'date', 'desc'); + this.registerFilterableColumn('js-asset-type-col', gettext('Type'), 'asset_type'); this.setInitialSortColumn('date-col'); + this.setInitialFilterColumn('js-asset-type-col'); } }); @@ -183,6 +185,7 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "URI", it('returns the registered info for a column', function () { pagingView.registerSortableColumn('test-col', 'Test Column', 'testField', 'asc'); + pagingView.registerFilterableColumn('js-asset-type-col', gettext('Type'), 'asset_type'); var sortInfo = pagingView.sortableColumnInfo('test-col'); expect(sortInfo.displayName).toBe('Test Column'); expect(sortInfo.fieldName).toBe('testField'); diff --git a/cms/static/js/views/asset.js b/cms/static/js/views/asset.js index 074313fa7b..bc3965a39a 100644 --- a/cms/static/js/views/asset.js +++ b/cms/static/js/views/asset.js @@ -20,6 +20,7 @@ var AssetView = BaseView.extend({ url: this.model.get('url'), external_url: this.model.get('external_url'), portable_url: this.model.get('portable_url'), + asset_type: this.model.get_extension(), uniqueId: uniqueId })); this.updateLockState(); diff --git a/cms/static/js/views/assets.js b/cms/static/js/views/assets.js index db15583a62..7cb6cd2e4a 100644 --- a/cms/static/js/views/assets.js +++ b/cms/static/js/views/assets.js @@ -10,9 +10,16 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", events : { "click .column-sort-link": "onToggleColumn", - "click .upload-button": "showUploadModal" + "click .upload-button": "showUploadModal", + "click .filterable-column .nav-item": "onFilterColumn", + "click .filterable-column .column-filter-link": "toggleFilterColumn" }, + typeData: ['Images', 'Documents'], + + allLabel: 'ALL', + + initialize : function(options) { options = options || {}; @@ -22,7 +29,9 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", this.listenTo(collection, 'destroy', this.handleDestroy); this.registerSortableColumn('js-asset-name-col', gettext('Name'), 'display_name', 'asc'); this.registerSortableColumn('js-asset-date-col', gettext('Date Added'), 'date_added', 'desc'); + this.registerFilterableColumn('js-asset-type-col', gettext('Type'), 'asset_type'); this.setInitialSortColumn('js-asset-date-col'); + this.setInitialFilterColumn('js-asset-type-col'); ViewUtils.showLoadingIndicator(); this.setPage(0); // set default file size for uploads via template var, @@ -56,7 +65,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", ViewUtils.hideLoadingIndicator(); // Create the table - this.$el.html(this.template()); + this.$el.html(this.template({typeData: this.typeData})); tableBody = this.$('#asset-table-body'); this.tableBody = tableBody; this.pagingHeader = new PagingHeader({view: this, el: $('#asset-paging-header')}); @@ -74,7 +83,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", renderPageItems: function() { var self = this, assets = this.collection, - hasAssets = assets.length > 0, + hasAssets = this.collection.assetType !== '' || assets.length > 0, tableBody = this.getTableBody(); tableBody.empty(); if (hasAssets) { @@ -106,6 +115,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", // Switch the sort column back to the default (most recent date added) and show the first page // so that the new asset is shown at the top of the page. this.setInitialSortColumn('js-asset-date-col'); + this.setInitialFilterColumn('js-asset-type-col'); this.setPage(0); analytics.track('Uploaded a File', { @@ -119,6 +129,11 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", this.toggleSortOrder(columnName); }, + onFilterColumn: function(event) { + this.openFilterColumn($(event.currentTarget)); + event.stopPropagation(); + }, + hideModal: function (event) { if (event) { event.preventDefault(); @@ -222,6 +237,65 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", $('.upload-modal .progress-fill').html(percentVal); }, + openFilterColumn: function($this) { + this.toggleFilterColumnState($this); + }, + + toggleFilterColumnState: function(menu, selected) { + var $subnav = menu.find('.wrapper-nav-sub'); + var $title = menu.find('.title'); + var titleText = $title.find('.type-filter'); + var assettype = selected ? selected.data('assetfilter'): false; + if(assettype) { + if(assettype === this.allLabel) { + titleText.text(titleText.data('alllabel')); + } + else { + titleText.text(assettype); + } + } + if ($subnav.hasClass('is-shown')) { + $subnav.removeClass('is-shown'); + $title.removeClass('is-selected'); + } else { + $title.addClass('is-selected'); + $subnav.addClass('is-shown'); + } + }, + + toggleFilterColumn: function(event) { + event.preventDefault(); + var $filterColumn = $(event.currentTarget); + this._toggleFilterColumn($filterColumn.data('assetfilter'), $filterColumn.text()); + }, + + _toggleFilterColumn: function(assettype, assettypeLabel) { + var collection = this.collection; + var filterColumn = this.$el.find('.filterable-column'); + var resetFilter = filterColumn.find('.reset-filter'); + var title = filterColumn.find('.title'); + if(assettype === this.allLabel) { + collection.assetType = ''; + resetFilter.hide(); + title.removeClass('column-selected-link'); + } + else { + collection.assetType = assettype; + resetFilter.show(); + title.addClass('column-selected-link'); + } + + this.filterableColumns['js-asset-type-col'].displayName = assettypeLabel; + this.selectFilter('js-asset-type-col'); + this.closeFilterPopup(this.$el.find( + '.column-filter-link[data-assetfilter="' + assettype + '"]')); + }, + + closeFilterPopup: function(element){ + var $menu = element.parents('.nav-dd > .nav-item'); + this.toggleFilterColumnState($menu, element); + }, + displayFinishedUpload: function (resp) { var asset = resp.asset; diff --git a/cms/static/js/views/paging.js b/cms/static/js/views/paging.js index c4d9b1b602..3fa12a3498 100644 --- a/cms/static/js/views/paging.js +++ b/cms/static/js/views/paging.js @@ -6,6 +6,10 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext", sortableColumns: {}, + filterableColumns: {}, + + filterColumn: '', + initialize: function() { BaseView.prototype.initialize.call(this); var collection = this.collection; @@ -25,6 +29,51 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext", // Do nothing by default }, + nextPage: function() { + var collection = this.collection, + currentPage = collection.currentPage, + lastPage = collection.totalPages - 1; + if (currentPage < lastPage) { + this.setPage(currentPage + 1); + } + }, + + previousPage: function() { + var collection = this.collection, + currentPage = collection.currentPage; + if (currentPage > 0) { + this.setPage(currentPage - 1); + } + }, + + registerFilterableColumn: function(columnName, displayName, fieldName) { + this.filterableColumns[columnName] = { + displayName: displayName, + fieldName: fieldName + }; + }, + + filterableColumnInfo: function(filterColumn) { + var filterInfo = this.filterableColumns[filterColumn]; + if (!filterInfo) { + throw "Unregistered filter column '" + filterInfo + '"'; + } + return filterInfo; + }, + + filterDisplayName: function() { + var filterColumn = this.filterColumn, + filterInfo = this.filterableColumnInfo(filterColumn); + return filterInfo.displayName; + }, + + setInitialFilterColumn: function(filterColumn) { + var collection = this.collection, + filtertInfo = this.filterableColumns[filterColumn]; + collection.filterField = filtertInfo.fieldName; + this.filterColumn = filterColumn; + }, + /** * Registers information about a column that can be sorted. * @param columnName The element name of the column. @@ -75,6 +124,18 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext", } 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; diff --git a/cms/static/js/views/paging_header.js b/cms/static/js/views/paging_header.js index c05ecc1dbc..4209d7229c 100644 --- a/cms/static/js/views/paging_header.js +++ b/cms/static/js/views/paging_header.js @@ -31,17 +31,40 @@ define(["underscore", "gettext", "js/views/baseview"], function(_, gettext, Base }, messageHtml: function() { - var message; - 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'); + 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 '

' + interpolate(message, { current_item_range: this.currentItemRangeLabel(), total_items_count: this.totalItemsCountLabel(), + asset_type: asset_type, sort_name: this.sortNameLabel() }, true) + "

"; }, @@ -75,6 +98,12 @@ define(["underscore", "gettext", "js/views/baseview"], function(_, gettext, Base }, true); }, + filterNameLabel: function() { + return interpolate('%(filter_name)s', { + filter_name: this.view.filterDisplayName() + }, true); + }, + nextPage: function() { this.view.nextPage(); }, diff --git a/cms/static/sass/views/_assets.scss b/cms/static/sass/views/_assets.scss index cedcb4eabe..4b5d7aaf70 100644 --- a/cms/static/sass/views/_assets.scss +++ b/cms/static/sass/views/_assets.scss @@ -43,6 +43,351 @@ } } + .assets-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, + .filter-column, + .sort-order { + @extend %t-strong; + } + } + + .pagination { + @include clearfix; + display: inline-block; + width: flex-grid(3, 12); + + &.pagination-compact { + @include text-align(right); + } + + &.pagination-full { + display: block; + width: flex-grid(4, 12); + margin: $baseline auto; + } + + .nav-item { + position: relative; + display: inline-block; + } + + .nav-link { + @include transition(all $tmg-f2 ease-in-out 0s); + display: block; + padding: ($baseline/4) ($baseline*0.75); + + &.previous { + margin-right: ($baseline/2); + } + + &.next { + margin-left: ($baseline/2); + } + + &:hover { + background-color: $blue; + border-radius: 3px; + color: $white; + } + + &.is-disabled { + background-color: transparent; + color: $gray-l2; + pointer-events: none; + } + } + + .nav-label { + @extend .sr; + } + + .pagination-form, + .current-page, + .page-divider, + .total-pages { + display: inline-block; + } + + .current-page, + .page-number-input, + .total-pages { + @extend %t-copy-base; + @extend %t-strong; + width: ($baseline*2.5); + margin: 0 ($baseline*0.75); + padding: ($baseline/4); + text-align: center; + color: $gray; + } + + .current-page { + @extend %ui-depth1; + position: absolute; + @include left(-($baseline/4)); + } + + .page-divider { + @extend %t-title4; + @extend %t-regular; + vertical-align: middle; + color: $gray-l2; + } + + + .pagination-form { + @extend %ui-depth2; + position: relative; + + .page-number-label, + .submit-pagination-form { + @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; + + th { + @extend %t-copy-sub2; + background-color: $gray-l5; + padding: 0 ($baseline/2) ($baseline*0.75) ($baseline/2); + vertical-align: middle; + text-align: left; + color: $gray; + + .column-sort-link, .column-selected-link { + cursor: pointer; + color: $blue; + } + + .current-sort { + @extend %t-strong; + border-bottom: 1px solid $gray-l3; + } + + // CASE: embed column + &.embed-col { + padding-left: ($baseline*0.75); + padding-right: ($baseline*0.75); + } + + &.nav-dd{ + // basic layout - nav items + margin: 0 -($baseline/2); + color: $blue; + cursor: pointer; + .wrapper-nav-sub { + top: 35px; + @extend %ui-depth2; + + > ol > .nav-item { + @extend %t-action3; + @extend %t-strong; + display: inline-block; + vertical-align: middle; + + &:last-child { + margin-right: 0; + } + } + .nav-sub { + @include text-align(left); + + // ui triangle/nub + &:after { + left: $baseline; + margin-left: -10px; + } + + &:before { + left: $baseline; + margin-left: -11px; + } + + .nav-item { + &.reset-filter{ + display:none; + } + + a { + color: $gray-d1; + + &:hover { + color: $blue-s1; + } + } + } + } + } + } + } + + td { + padding: ($baseline/2); + vertical-align: middle; + text-align: left; + } + + tbody { + box-shadow: 0 2px 2px $shadow-l1; + border: 1px solid $gray-l4; + background: $white; + + tr { + @include transition(all $tmg-f2 ease-in-out 0s); + border-top: 1px solid $gray-l4; + + &:first-child { + border-top: none; + } + + &:nth-child(odd) { + background-color: $gray-l6; + } + + a { + color: $gray-d1; + + &:hover { + color: $blue; + } + } + + &.is-locked { + background-image: url('../images/bg-micro-stripes.png'); + background-position: 0 0; + background-repeat: repeat; + } + + &:hover { + background-color: $blue-l5; + + .date-col, + .embed-col, + .embed-col .embeddable-xml-input { + color: $gray; + } + } + } + + .thumb-col { + padding: ($baseline/2) $baseline; + @extend %t-copy-sub2; + color: $gray-l2; + + .thumb { + width: 100px; + } + + img { + width: 100%; + } + } + + + .name-col { + + .title { + @extend %t-copy-sub1; + display: inline-block; + max-width: 200px; + overflow: hidden; + } + } + + .type-col { + @extend %t-copy-sub2; + color: $gray-l2; + } + + .date-col { + @include transition(all $tmg-f2 ease-in-out 0s); + @extend %t-copy-sub2; + color: $gray-l2; + } + + .embed-col { + @include transition(all $tmg-f2 ease-in-out 0s); + @extend %t-copy-sub2; + padding-left: ($baseline*0.75); + color: $gray-l2; + + .label { + display: inline-block; + width: ($baseline*2); + } + + .embeddable-xml-input { + @include transition(all $tmg-f2 ease-in-out 0s); + @extend %t-copy-sub2; + box-shadow: none; + border: 1px solid transparent; + background: none; + padding: ($baseline/5); + color: $gray-l2; + + &:focus { + background-color: $white; + box-shadow: 0 1px 5px $shadow-l1 inset; + border: 1px solid $gray-l3; + color: $black; + } + } + } + + .actions-col { + padding: ($baseline/2); + text-align: center; + } + } + } + } + // UI: assets - calls-to-action .actions-list { @extend %actions-list; diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index f6967aff7a..86d2a6ce48 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -70,10 +70,11 @@

${_("Using File URLs")}

-

${_("Use the {em_start}Embed URL{em_end} value to link to the file or image from a component, a course update, or a course handout.").format(em_start='', em_end="")}

+

${_("Use the {em_start}{studio_name} URL{em_end} value to link to the file or image from a component, a course update, or a course handout.").format(studio_name=settings.STUDIO_SHORT_NAME, em_start="", em_end="")}

-

${_("Use the {em_start}External URL{em_end} value to reference the file or image only from outside of your course.").format(em_start='', em_end="")}

-

${_("Click in the Embed URL or External URL column to select the value, then copy it.")}

+

${_("Use the {em_start}Web URL{em_end} value to reference the file or image only from outside of your course. {em_start}Note:{em_end} If you lock a file, the Web URL no longer works for external access to a file.").format(em_start='', em_end="")}

+ +

${_("To copy a URL, double click the value in the URL column, then copy the selected text.")}

${_("Learn more about managing files")} diff --git a/cms/templates/js/asset-library.underscore b/cms/templates/js/asset-library.underscore index dd2b22b8bc..3743ebd204 100644 --- a/cms/templates/js/asset-library.underscore +++ b/cms/templates/js/asset-library.underscore @@ -1,11 +1,12 @@
- + + @@ -13,10 +14,46 @@ - - - - + + + + @@ -27,5 +64,5 @@
-

<%= gettext("You haven't added any assets to this course yet.") %> <%= gettext("Upload your first asset") %>

+

<%= gettext("You haven't added any assets to this course yet.") %> <%= gettext("Upload your first asset") %>

diff --git a/cms/templates/js/asset-upload-modal.underscore b/cms/templates/js/asset-upload-modal.underscore index 9c6d14df83..3b146affd5 100644 --- a/cms/templates/js/asset-upload-modal.underscore +++ b/cms/templates/js/asset-upload-modal.underscore @@ -1,5 +1,5 @@ @@ -10,24 +10,38 @@
+ - diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 6fa338aa13..1adad2f623 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -104,8 +104,7 @@
<%= gettext("List of uploaded files and assets in this course") %>
<%= gettext("Preview") %><%= gettext("Name") %><%= gettext("Date Added") %><%= gettext("Embed URL") %><%= gettext("External URL") %> + + <%= gettext("Name") %> + <%= gettext("- Sortable") %> + + + + + + <%= gettext("Date Added") %> + <%= gettext("- Sortable") %> + + <%= gettext("URL") %> <%= gettext("Actions") %>
<% if (thumbnail !== '') { %> - + <%= gettext('No description available') %> <% } %>
+ <%= asset_type %> + <%= date_added %> - - - +
    +
  • + +
  • +
  • + +
  • +
+