Commit dcc8e956 by Martyn James

Implements SOL-20. Filtering for assets table by asset type

parent c4cd94d7
...@@ -31,8 +31,9 @@ from xmodule.modulestore.exceptions import ItemNotFoundError ...@@ -31,8 +31,9 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
__all__ = ['assets_handler'] __all__ = ['assets_handler']
# pylint: disable=unused-argument # pylint: disable=unused-argument
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def assets_handler(request, course_key_string=None, asset_key_string=None): def assets_handler(request, course_key_string=None, asset_key_string=None):
...@@ -99,6 +100,29 @@ def _assets_json(request, course_key): ...@@ -99,6 +100,29 @@ def _assets_json(request, course_key):
requested_page = int(request.REQUEST.get('page', 0)) requested_page = int(request.REQUEST.get('page', 0))
requested_page_size = int(request.REQUEST.get('page_size', 50)) requested_page_size = int(request.REQUEST.get('page_size', 50))
requested_sort = request.REQUEST.get('sort', 'date_added') 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 sort_direction = DESCENDING
if request.REQUEST.get('direction', '').lower() == 'asc': if request.REQUEST.get('direction', '').lower() == 'asc':
sort_direction = ASCENDING sort_direction = ASCENDING
...@@ -112,26 +136,42 @@ def _assets_json(request, course_key): ...@@ -112,26 +136,42 @@ def _assets_json(request, course_key):
current_page = max(requested_page, 0) current_page = max(requested_page, 0)
start = current_page * 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) 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) 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: 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 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) end = start + len(assets)
asset_json = [] asset_json = []
for asset in assets: for asset in assets:
asset_location = asset['asset_key'] 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) thumbnail_location = asset.get('thumbnail_location', None)
if thumbnail_location: 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_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({ return JsonResponse({
'start': start, 'start': start,
...@@ -144,14 +184,18 @@ def _assets_json(request, course_key): ...@@ -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. 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 start = current_page * page_size
return contentstore().get_all_content_for_course( 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): ...@@ -239,10 +283,16 @@ def _upload_asset(request, course_key):
# readback the saved content - we need the database timestamp # readback the saved content - we need the database timestamp
readback = contentstore().find(content.location) readback = contentstore().find(content.location)
locked = getattr(content, 'locked', False) locked = getattr(content, 'locked', False)
response_payload = { 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') 'msg': _('Upload completed')
} }
...@@ -305,7 +355,7 @@ def _update_asset(request, course_key, asset_key): ...@@ -305,7 +355,7 @@ def _update_asset(request, course_key, asset_key):
return JsonResponse(modified_asset, status=201) 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. 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): ...@@ -313,6 +363,7 @@ def _get_asset_json(display_name, date, location, thumbnail_location, locked):
external_url = settings.LMS_BASE + asset_url external_url = settings.LMS_BASE + asset_url
return { return {
'display_name': display_name, 'display_name': display_name,
'content_type': content_type,
'date_added': get_default_time_display(date), 'date_added': get_default_time_display(date),
'url': asset_url, 'url': asset_url,
'external_url': external_url, 'external_url': external_url,
......
...@@ -34,17 +34,17 @@ class AssetsTestCase(CourseTestCase): ...@@ -34,17 +34,17 @@ class AssetsTestCase(CourseTestCase):
super(AssetsTestCase, self).setUp() super(AssetsTestCase, self).setUp()
self.url = reverse_course_url('assets_handler', self.course.id) 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 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}) 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""" """Returns an in-memory file with the given name for testing"""
f = BytesIO(name) f = BytesIO(name)
f.name = name + ".txt" f.name = name + extension
return f return f
...@@ -98,20 +98,60 @@ class PaginationTestCase(AssetsTestCase): ...@@ -98,20 +98,60 @@ class PaginationTestCase(AssetsTestCase):
self.upload_asset("asset-1") self.upload_asset("asset-1")
self.upload_asset("asset-2") self.upload_asset("asset-2")
self.upload_asset("asset-3") self.upload_asset("asset-3")
self.upload_asset("asset-4", ".odt")
# Verify valid page requests # Verify valid page requests
self.assert_correct_asset_response(self.url, 0, 3, 3) self.assert_correct_asset_response(self.url, 0, 4, 4)
self.assert_correct_asset_response(self.url + "?page_size=2", 0, 2, 3) 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, 1, 3) 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', 'asc')
self.assert_correct_sort_response(self.url, 'date_added', 'desc') 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', 'asc')
self.assert_correct_sort_response(self.url, 'display_name', 'desc') 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 # 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.assert_correct_asset_response(self.url + "?page_size=2&page=2", 2, 1, 3) self.url + "?page_size=2&page=-1", 0, 2, 4)
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=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): def assert_correct_asset_response(self, url, expected_start, expected_length, expected_total):
""" """
...@@ -128,7 +168,8 @@ class PaginationTestCase(AssetsTestCase): ...@@ -128,7 +168,8 @@ class PaginationTestCase(AssetsTestCase):
""" """
Get from the url w/ a sort option and ensure items honor that sort 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) json_response = json.loads(resp.content)
assets_response = json_response['assets'] assets_response = json_response['assets']
name1 = assets_response[0][sort] name1 = assets_response[0][sort]
...@@ -141,6 +182,29 @@ class PaginationTestCase(AssetsTestCase): ...@@ -141,6 +182,29 @@ class PaginationTestCase(AssetsTestCase):
self.assertGreaterEqual(name1, name2) self.assertGreaterEqual(name1, name2)
self.assertGreaterEqual(name2, name3) 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 @ddt
class UploadTestCase(AssetsTestCase): class UploadTestCase(AssetsTestCase):
...@@ -229,13 +293,13 @@ class AssetToJsonTestCase(AssetsTestCase): ...@@ -229,13 +293,13 @@ class AssetToJsonTestCase(AssetsTestCase):
@override_settings(LMS_BASE="lms_base_url") @override_settings(LMS_BASE="lms_base_url")
def test_basic(self): def test_basic(self):
upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC) upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC)
content_type = 'image/jpg'
course_key = SlashSeparatedCourseKey('org', 'class', 'run') course_key = SlashSeparatedCourseKey('org', 'class', 'run')
location = course_key.make_asset_key('asset', 'my_file_name.jpg') location = course_key.make_asset_key('asset', 'my_file_name.jpg')
thumbnail_location = course_key.make_asset_key('thumbnail', 'my_file_name_thumb.jpg') thumbnail_location = course_key.make_asset_key('thumbnail', 'my_file_name_thumb.jpg')
# pylint: disable=protected-access # 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["display_name"], "my_file")
self.assertEquals(output["date_added"], "Jun 01, 2013 at 10:30 UTC") self.assertEquals(output["date_added"], "Jun 01, 2013 at 10:30 UTC")
...@@ -246,7 +310,7 @@ class AssetToJsonTestCase(AssetsTestCase): ...@@ -246,7 +310,7 @@ class AssetToJsonTestCase(AssetsTestCase):
self.assertEquals(output["id"], unicode(location)) self.assertEquals(output["id"], unicode(location))
self.assertEquals(output['locked'], True) 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"]) self.assertIsNone(output["thumbnail"])
...@@ -267,6 +331,7 @@ class LockAssetTestCase(AssetsTestCase): ...@@ -267,6 +331,7 @@ class LockAssetTestCase(AssetsTestCase):
def post_asset_update(lock, course): def post_asset_update(lock, course):
""" Helper method for posting asset update. """ """ Helper method for posting asset update. """
content_type = 'application/txt'
upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC) upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC)
asset_location = course.id.make_asset_key('asset', 'sample_static.txt') 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)}) url = reverse_course_url('assets_handler', course.id, kwargs={'asset_key_string': unicode(asset_location)})
...@@ -274,9 +339,11 @@ class LockAssetTestCase(AssetsTestCase): ...@@ -274,9 +339,11 @@ class LockAssetTestCase(AssetsTestCase):
resp = self.client.post( resp = self.client.post(
url, url,
# pylint: disable=protected-access # 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" "application/json"
) )
self.assertEqual(resp.status_code, 201) self.assertEqual(resp.status_code, 201)
return json.loads(resp.content) return json.loads(resp.content)
......
...@@ -820,5 +820,26 @@ ADVANCED_PROBLEM_TYPES = [ ...@@ -820,5 +820,26 @@ ADVANCED_PROBLEM_TYPES = [
'boilerplate_name': None, 'boilerplate_name': None,
} }
] ]
#date format the api will be formatting the datetime values #date format the api will be formatting the datetime values
API_DATE_FORMAT = '%Y-%m-%d' 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',
],
}
...@@ -34,7 +34,7 @@ domReady(function() { ...@@ -34,7 +34,7 @@ domReady(function() {
$('.nav-dd .nav-item .title').removeClass('is-selected'); $('.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'); $subnav = $(this).find('.wrapper-nav-sub');
$title = $(this).find('.title'); $title = $(this).find('.title');
......
define(["backbone.paginator", "js/models/asset"], function(BackbonePaginator, AssetModel) { define(["backbone.paginator", "js/models/asset"], function(BackbonePaginator, AssetModel) {
var AssetCollection = BackbonePaginator.requestPager.extend({ var AssetCollection = BackbonePaginator.requestPager.extend({
assetType: '',
model : AssetModel, model : AssetModel,
paginator_core: { paginator_core: {
type: 'GET', type: 'GET',
...@@ -17,6 +18,7 @@ define(["backbone.paginator", "js/models/asset"], function(BackbonePaginator, As ...@@ -17,6 +18,7 @@ define(["backbone.paginator", "js/models/asset"], function(BackbonePaginator, As
'page_size': function() { return this.perPage; }, 'page_size': function() { return this.perPage; },
'sort': function() { return this.sortField; }, 'sort': function() { return this.sortField; },
'direction': function() { return this.sortDirection; }, 'direction': function() { return this.sortDirection; },
'asset_type': function() { return this.assetType; },
'format': 'json' 'format': 'json'
}, },
......
...@@ -5,12 +5,18 @@ define(["backbone"], function(Backbone) { ...@@ -5,12 +5,18 @@ define(["backbone"], function(Backbone) {
var Asset = Backbone.Model.extend({ var Asset = Backbone.Model.extend({
defaults: { defaults: {
display_name: "", display_name: "",
content_type: "",
thumbnail: "", thumbnail: "",
date_added: "", date_added: "",
url: "", url: "",
external_url: "", external_url: "",
portable_url: "", portable_url: "",
locked: false 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; return Asset;
......
...@@ -35,14 +35,14 @@ define( ...@@ -35,14 +35,14 @@ define(
var makeUploadUrl = function(fileName) { var makeUploadUrl = function(fileName) {
return "http://www.example.com/test_url/" + fileName; return "http://www.example.com/test_url/" + fileName;
} };
var getSentRequests = function() { var getSentRequests = function() {
return _.filter( return _.filter(
ajaxRequests, ajaxRequests,
function(request) { return request.readyState > 0; } function(request) { return request.readyState > 0; }
); );
} };
_.each( _.each(
[ [
......
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"], "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() { describe("Assets", function() {
var assetsView, mockEmptyAssetsResponse, mockAssetUploadResponse, mockFileUpload, var assetsView, mockEmptyAssetsResponse, mockAssetUploadResponse, mockFileUpload,
...@@ -28,6 +28,7 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "js/views/asset", "js/views ...@@ -28,6 +28,7 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "js/views/asset", "js/views
collection: collection, collection: collection,
el: $('#asset_table_body') el: $('#asset_table_body')
}); });
assetsView.render(); assetsView.render();
}); });
...@@ -50,6 +51,68 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "js/views/asset", "js/views ...@@ -50,6 +51,68 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "js/views/asset", "js/views
totalCount: 0 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 = { mockAssetUploadResponse = {
asset: mockAsset, asset: mockAsset,
msg: "Upload completed" msg: "Upload completed"
...@@ -59,16 +122,30 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "js/views/asset", "js/views ...@@ -59,16 +122,30 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "js/views/asset", "js/views
files: [{name: 'largefile', size: 0}] 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"}; event.target = {"value": "dummy.jpg"};
describe("AssetsView", function () { describe("AssetsView", function () {
var setup; var setup;
setup = function() { setup = function(responseData) {
var requests; var requests = AjaxHelpers.requests(this);
requests = AjaxHelpers.requests(this);
assetsView.setPage(0); assetsView.setPage(0);
AjaxHelpers.respondWithJson(requests, mockEmptyAssetsResponse); if (!responseData){
AjaxHelpers.respondWithJson(requests, mockEmptyAssetsResponse);
}
else{
AjaxHelpers.respondWithJson(requests, responseData);
}
return requests; return requests;
}; };
...@@ -170,6 +247,106 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "js/views/asset", "js/views ...@@ -170,6 +247,106 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "js/views/asset", "js/views
expect(assetsView.largeFileErrorMsg).toBeNull(); 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() { it('hides the error modal if a large file, then small file is uploaded', function() {
expect(assetsView).toBeDefined(); expect(assetsView).toBeDefined();
mockFileUpload.files[0].size = assetsView.maxFileSize; mockFileUpload.files[0].size = assetsView.maxFileSize;
......
...@@ -58,7 +58,9 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "URI", ...@@ -58,7 +58,9 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "URI",
initialize : function() { initialize : function() {
this.registerSortableColumn('name-col', 'Name', 'name', 'asc'); this.registerSortableColumn('name-col', 'Name', 'name', 'asc');
this.registerSortableColumn('date-col', 'Date', 'date', 'desc'); this.registerSortableColumn('date-col', 'Date', 'date', 'desc');
this.registerFilterableColumn('js-asset-type-col', gettext('Type'), 'asset_type');
this.setInitialSortColumn('date-col'); this.setInitialSortColumn('date-col');
this.setInitialFilterColumn('js-asset-type-col');
} }
}); });
...@@ -183,6 +185,7 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "URI", ...@@ -183,6 +185,7 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "URI",
it('returns the registered info for a column', function () { it('returns the registered info for a column', function () {
pagingView.registerSortableColumn('test-col', 'Test Column', 'testField', 'asc'); pagingView.registerSortableColumn('test-col', 'Test Column', 'testField', 'asc');
pagingView.registerFilterableColumn('js-asset-type-col', gettext('Type'), 'asset_type');
var sortInfo = pagingView.sortableColumnInfo('test-col'); var sortInfo = pagingView.sortableColumnInfo('test-col');
expect(sortInfo.displayName).toBe('Test Column'); expect(sortInfo.displayName).toBe('Test Column');
expect(sortInfo.fieldName).toBe('testField'); expect(sortInfo.fieldName).toBe('testField');
......
...@@ -20,6 +20,7 @@ var AssetView = BaseView.extend({ ...@@ -20,6 +20,7 @@ var AssetView = BaseView.extend({
url: this.model.get('url'), url: this.model.get('url'),
external_url: this.model.get('external_url'), external_url: this.model.get('external_url'),
portable_url: this.model.get('portable_url'), portable_url: this.model.get('portable_url'),
asset_type: this.model.get_extension(),
uniqueId: uniqueId uniqueId: uniqueId
})); }));
this.updateLockState(); this.updateLockState();
......
...@@ -10,9 +10,16 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", ...@@ -10,9 +10,16 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
events : { events : {
"click .column-sort-link": "onToggleColumn", "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) { initialize : function(options) {
options = options || {}; options = options || {};
...@@ -22,7 +29,9 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", ...@@ -22,7 +29,9 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
this.listenTo(collection, 'destroy', this.handleDestroy); this.listenTo(collection, 'destroy', this.handleDestroy);
this.registerSortableColumn('js-asset-name-col', gettext('Name'), 'display_name', 'asc'); this.registerSortableColumn('js-asset-name-col', gettext('Name'), 'display_name', 'asc');
this.registerSortableColumn('js-asset-date-col', gettext('Date Added'), 'date_added', 'desc'); 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.setInitialSortColumn('js-asset-date-col');
this.setInitialFilterColumn('js-asset-type-col');
ViewUtils.showLoadingIndicator(); ViewUtils.showLoadingIndicator();
this.setPage(0); this.setPage(0);
// set default file size for uploads via template var, // set default file size for uploads via template var,
...@@ -56,7 +65,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", ...@@ -56,7 +65,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
ViewUtils.hideLoadingIndicator(); ViewUtils.hideLoadingIndicator();
// Create the table // Create the table
this.$el.html(this.template()); this.$el.html(this.template({typeData: this.typeData}));
tableBody = this.$('#asset-table-body'); tableBody = this.$('#asset-table-body');
this.tableBody = tableBody; this.tableBody = tableBody;
this.pagingHeader = new PagingHeader({view: this, el: $('#asset-paging-header')}); this.pagingHeader = new PagingHeader({view: this, el: $('#asset-paging-header')});
...@@ -74,7 +83,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", ...@@ -74,7 +83,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
renderPageItems: function() { renderPageItems: function() {
var self = this, var self = this,
assets = this.collection, assets = this.collection,
hasAssets = assets.length > 0, hasAssets = this.collection.assetType !== '' || assets.length > 0,
tableBody = this.getTableBody(); tableBody = this.getTableBody();
tableBody.empty(); tableBody.empty();
if (hasAssets) { if (hasAssets) {
...@@ -106,6 +115,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", ...@@ -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 // 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. // so that the new asset is shown at the top of the page.
this.setInitialSortColumn('js-asset-date-col'); this.setInitialSortColumn('js-asset-date-col');
this.setInitialFilterColumn('js-asset-type-col');
this.setPage(0); this.setPage(0);
analytics.track('Uploaded a File', { analytics.track('Uploaded a File', {
...@@ -119,6 +129,11 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", ...@@ -119,6 +129,11 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
this.toggleSortOrder(columnName); this.toggleSortOrder(columnName);
}, },
onFilterColumn: function(event) {
this.openFilterColumn($(event.currentTarget));
event.stopPropagation();
},
hideModal: function (event) { hideModal: function (event) {
if (event) { if (event) {
event.preventDefault(); event.preventDefault();
...@@ -222,6 +237,65 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", ...@@ -222,6 +237,65 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
$('.upload-modal .progress-fill').html(percentVal); $('.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) { displayFinishedUpload: function (resp) {
var asset = resp.asset; var asset = resp.asset;
......
...@@ -6,6 +6,10 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext", ...@@ -6,6 +6,10 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext",
sortableColumns: {}, sortableColumns: {},
filterableColumns: {},
filterColumn: '',
initialize: function() { initialize: function() {
BaseView.prototype.initialize.call(this); BaseView.prototype.initialize.call(this);
var collection = this.collection; var collection = this.collection;
...@@ -25,6 +29,51 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext", ...@@ -25,6 +29,51 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext",
// Do nothing by default // 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. * Registers information about a column that can be sorted.
* @param columnName The element name of the column. * @param columnName The element name of the column.
...@@ -75,6 +124,18 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext", ...@@ -75,6 +124,18 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext",
} }
this.sortColumn = sortColumn; this.sortColumn = sortColumn;
this.setPage(0); 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; return PagingView;
......
...@@ -31,17 +31,40 @@ define(["underscore", "gettext", "js/views/baseview"], function(_, gettext, Base ...@@ -31,17 +31,40 @@ define(["underscore", "gettext", "js/views/baseview"], function(_, gettext, Base
}, },
messageHtml: function() { messageHtml: function() {
var message; var message = '';
if (this.view.collection.sortDirection === 'asc') { var asset_type = false;
// Translators: sample result: "Showing 0-9 out of 25 total, sorted by Date Added ascending" if (this.view.collection.assetType) {
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, sorted by %(sort_name)s ascending'); if (this.view.collection.sortDirection === 'asc') {
} else { // Translators: sample result:
// Translators: sample result: "Showing 0-9 out of 25 total, sorted by Date Added descending" // "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, sorted by %(sort_name)s descending'); 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, { return '<p>' + interpolate(message, {
current_item_range: this.currentItemRangeLabel(), current_item_range: this.currentItemRangeLabel(),
total_items_count: this.totalItemsCountLabel(), total_items_count: this.totalItemsCountLabel(),
asset_type: asset_type,
sort_name: this.sortNameLabel() sort_name: this.sortNameLabel()
}, true) + "</p>"; }, true) + "</p>";
}, },
...@@ -75,6 +98,12 @@ define(["underscore", "gettext", "js/views/baseview"], function(_, gettext, Base ...@@ -75,6 +98,12 @@ define(["underscore", "gettext", "js/views/baseview"], function(_, gettext, Base
}, true); }, true);
}, },
filterNameLabel: function() {
return interpolate('<span class="filter-column">%(filter_name)s</span>', {
filter_name: this.view.filterDisplayName()
}, true);
},
nextPage: function() { nextPage: function() {
this.view.nextPage(); this.view.nextPage();
}, },
......
...@@ -43,6 +43,351 @@ ...@@ -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 // UI: assets - calls-to-action
.actions-list { .actions-list {
@extend %actions-list; @extend %actions-list;
......
...@@ -70,10 +70,11 @@ ...@@ -70,10 +70,11 @@
<div class="bit"> <div class="bit">
<h3 class="title-3">${_("Using File URLs")}</h3> <h3 class="title-3">${_("Using File URLs")}</h3>
<p>${_("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='<strong>', em_end="</strong>")}</p> <p>${_("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="<strong>", em_end="</strong>")}</p>
<p>${_("Use the {em_start}External URL{em_end} value to reference the file or image only from outside of your course.").format(em_start='<strong>', em_end="</strong>")}</p> <p>${_("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='<strong>', em_end="</strong>")}</p>
<p>${_("Click in the Embed URL or External URL column to select the value, then copy it.")}</p>
<p>${_("To copy a URL, double click the value in the URL column, then copy the selected text.")}</p>
</div> </div>
<div class="bit external-help"> <div class="bit external-help">
<a href="${get_online_help_info(online_help_token())['doc_url']}" target="_blank" class="button external-help-button">${_("Learn more about managing files")}</a> <a href="${get_online_help_info(online_help_token())['doc_url']}" target="_blank" class="button external-help-button">${_("Learn more about managing files")}</a>
......
<div class="assets-library"> <div class="assets-library">
<div id="asset-paging-header"></div> <div id="asset-paging-header"></div>
<table class="assets-table"> <table class="assets-table">
<caption class="sr"><%= gettext("List of uploaded files and assets in this course") %></caption> <caption class="sr"><%= gettext("List of uploaded files and assets in this course") %></caption>
<colgroup> <colgroup>
<col class="thumb-cols" /> <col class="thumb-cols" />
<col class="name-cols" /> <col class="name-cols" />
<col class="type-cols" />
<col class="date-cols" /> <col class="date-cols" />
<col class="embed-cols" /> <col class="embed-cols" />
<col class="actions-cols" /> <col class="actions-cols" />
...@@ -13,10 +14,46 @@ ...@@ -13,10 +14,46 @@
<thead> <thead>
<tr> <tr>
<th class="thumb-col"><%= gettext("Preview") %></th> <th class="thumb-col"><%= gettext("Preview") %></th>
<th class="name-col sortable-column"><span class="column-sort-link" id="js-asset-name-col"><%= gettext("Name") %></span></th> <th class="name-col sortable-column">
<th class="date-col sortable-column"><span class="column-sort-link" id="js-asset-date-col"><%= gettext("Date Added") %></span></th> <span class="column-sort-link" id="js-asset-name-col" role="button" tabindex="0">
<th class="embed-col"><%= gettext("Embed URL") %></th> <%= gettext("Name") %>
<th class="embed-col"><%= gettext("External URL") %></th> <span class="sr"><%= gettext("- Sortable") %></span>
</span>
</th>
<th class="type-col filterable-column nav-dd">
<div id="js-asset-type-col" class="nav-item" role="button" tabindex="0">
<span class="title">
<span class="type-filter" data-alllabel='<%= gettext("Type") %>'><%= gettext("Type") %></span>
<span class="label-prefix sr">Filter</span>
<span class="filter-link"></span>
<i class="fa fa-caret-down ui-toggle-dd" aria-hidden="true"></i>
</span>
<div class="wrapper-nav-sub">
<div class="nav-sub">
<ul>
<li class="nav-item reset-filter">
<a class="column-filter-link" href="" data-assetfilter="ALL">Show All</a>
</li>
<% _.each(typeData, function(type, key){ %>
<li class="nav-item">
<a class="column-filter-link" href="" data-assetfilter="<%= type %>"><%= type %></a>
</li>
<% }) %>
<li class="nav-item">
<a class="column-filter-link" href="" data-assetfilter="OTHER">Other</a>
</li>
</ul>
</div>
</div>
</div>
</th>
<th class="date-col sortable-column">
<span class="column-sort-link" id="js-asset-date-col">
<%= gettext("Date Added") %>
<span class="sr"><%= gettext("- Sortable") %></span>
</span>
</th>
<th class="embed-col"><%= gettext("URL") %></th>
<th class="actions-col"><span class="sr"><%= gettext("Actions") %></span></th> <th class="actions-col"><span class="sr"><%= gettext("Actions") %></span></th>
</tr> </tr>
</thead> </thead>
...@@ -27,5 +64,5 @@ ...@@ -27,5 +64,5 @@
</div> </div>
<div class="no-asset-content"> <div class="no-asset-content">
<p><%= gettext("You haven't added any assets to this course yet.") %> <a href="#" class="button new-button upload-button"><i class="icon fa fa-plus"></i><%= gettext("Upload your first asset") %></a></p> <p><%= gettext("You haven't added any assets to this course yet.") %> <a href="#" class="button new-button upload-button"><i class="icon fa fa-plus" aria-hidden="true"></i><%= gettext("Upload your first asset") %></a></p>
</div> </div>
<div class="upload-modal modal" style="display: none;"> <div class="upload-modal modal" style="display: none;">
<a href="#" class="close-button"><i class="icon fa fa-times-circle"></i> <span class="sr"><%= gettext('close') %></span></a> <a href="#" class="close-button"><i class="icon fa fa-times-circle" aria-hidden="true"></i> <span class="sr"><%= gettext('close') %></span></a>
<div class="modal-body"> <div class="modal-body">
<h1 class="title"><%= gettext("Upload New File") %></h1> <h1 class="title"><%= gettext("Upload New File") %></h1>
<p class="file-name"> <p class="file-name">
......
<td class="thumb-col"> <td class="thumb-col">
<div class="thumb"> <div class="thumb">
<% if (thumbnail !== '') { %> <% if (thumbnail !== '') { %>
<img src="<%= thumbnail %>"> <img src="<%= thumbnail %>" alt="<%= gettext('No description available') %>">
<% } %> <% } %>
</div> </div>
</td> </td>
...@@ -10,24 +10,38 @@ ...@@ -10,24 +10,38 @@
<div class="embeddable-xml"></div> <div class="embeddable-xml"></div>
</td> </td>
<td class="type-col">
<%= asset_type %>
</td>
<td class="date-col"> <td class="date-col">
<%= date_added %> <%= date_added %>
</td> </td>
<td class="embed-col"> <td class="embed-col">
<input type="text" class="embeddable-xml-input" value="<%= portable_url %>" readonly dir="ltr"> <ul>
</td> <li class="embed-url">
<td class="embed-col"> <label>
<input type="text" class="embeddable-xml-input" value="<%= external_url %>" readonly> <span class="label"><%= gettext('Studio:') %></span>
<input type="text" class="embeddable-xml-input" value="<%= portable_url %>" readonly>
</label>
</li>
<li class="external-url">
<label>
<span class="label"><%= gettext('Web:') %></span>
<input type="text" class="embeddable-xml-input" value="<%= external_url %>" readonly>
</label>
</li>
</ul>
</td> </td>
<td class="actions-col"> <td class="actions-col">
<ul class="actions-list"> <ul class="actions-list">
<li class="action-item action-delete"> <li class="action-item action-delete">
<a href="#" data-tooltip="<%= gettext('Delete this asset') %>" class="remove-asset-button action-button"><i class="icon fa fa-times-circle"></i> <span class="sr"><%= gettext('Delete this asset') %></span></a> <a href="#" data-tooltip="<%= gettext('Delete this asset') %>" class="remove-asset-button action-button"><i class="icon fa fa-times-circle" aria-hidden="true"></i> <span class="sr"><%= gettext('Delete this asset') %></span></a>
</li> </li>
<li class="action-item action-lock"> <li class="action-item action-lock">
<label for="<%= uniqueId %>"><span class="sr"><%= gettext('Lock this asset') %></span></label> <label for="<%= uniqueId %>"><span class="sr"><%= gettext('Lock this asset') %></span></label>
<input type="checkbox" id="<%= uniqueId %>" class="lock-checkbox" data-tooltip="<%= gettext('Lock/unlock file') %>" /> <input type="checkbox" id="<%= uniqueId %>" class="lock-checkbox" data-tooltip="<%= gettext('Lock/unlock file') %>" />
<div class="action-button"><i class="icon fa fa-lock"></i><i class="icon fa fa-unlock-alt"></i></div> <div class="action-button"><i class="icon fa fa-lock"></i><i class="icon fa fa-unlock-alt" aria-hidden="true"></i></div>
</li> </li>
</ul> </ul>
</td> </td>
...@@ -104,8 +104,7 @@ ...@@ -104,8 +104,7 @@
</li> </li>
<li class="nav-item nav-course-tools"> <li class="nav-item nav-course-tools">
<h3 class="title"><span class="label">${_("Tools")}</span> <i class="icon fa fa-caret-down ui-toggle-dd"></i></h3> <h3 class="title"><span class="label">${_("Tools")}</span> <i class="icon fa fa-caret-down ui-toggle-dd" aria-hidden="true"></i></h3>
<div class="wrapper wrapper-nav-sub"> <div class="wrapper wrapper-nav-sub">
<div class="nav-sub"> <div class="nav-sub">
<ul> <ul>
......
...@@ -220,7 +220,7 @@ class ContentStore(object): ...@@ -220,7 +220,7 @@ class ContentStore(object):
def find(self, filename): def find(self, filename):
raise NotImplementedError raise NotImplementedError
def get_all_content_for_course(self, course_key, start=0, maxresults=-1, sort=None): def get_all_content_for_course(self, course_key, start=0, maxresults=-1, sort=None, filter_params=None):
''' '''
Returns a list of static assets for a course, followed by the total number of assets. 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. By default all assets are returned, but start and maxresults can be provided to limit the query.
......
...@@ -172,9 +172,9 @@ class MongoContentStore(ContentStore): ...@@ -172,9 +172,9 @@ class MongoContentStore(ContentStore):
def get_all_content_thumbnails_for_course(self, course_key): def get_all_content_thumbnails_for_course(self, course_key):
return self._get_all_content_for_course(course_key, get_thumbnails=True)[0] return self._get_all_content_for_course(course_key, get_thumbnails=True)[0]
def get_all_content_for_course(self, course_key, start=0, maxresults=-1, sort=None): def get_all_content_for_course(self, course_key, start=0, maxresults=-1, sort=None, filter_params=None):
return self._get_all_content_for_course( return self._get_all_content_for_course(
course_key, start=start, maxresults=maxresults, get_thumbnails=False, sort=sort course_key, start=start, maxresults=maxresults, get_thumbnails=False, sort=sort, filter_params=filter_params
) )
def remove_redundant_content_for_courses(self): def remove_redundant_content_for_courses(self):
...@@ -197,7 +197,13 @@ class MongoContentStore(ContentStore): ...@@ -197,7 +197,13 @@ class MongoContentStore(ContentStore):
self.fs_files.remove(query) self.fs_files.remove(query)
return assets_to_delete return assets_to_delete
def _get_all_content_for_course(self, course_key, get_thumbnails=False, start=0, maxresults=-1, sort=None): def _get_all_content_for_course(self,
course_key,
get_thumbnails=False,
start=0,
maxresults=-1,
sort=None,
filter_params=None):
''' '''
Returns a list of all static assets for a course. The return format is a list of asset data dictionary elements. Returns a list of all static assets for a course. The return format is a list of asset data dictionary elements.
...@@ -208,15 +214,17 @@ class MongoContentStore(ContentStore): ...@@ -208,15 +214,17 @@ class MongoContentStore(ContentStore):
contentType: The mimetype string of the asset contentType: The mimetype string of the asset
md5: An md5 hash of the asset content md5: An md5 hash of the asset content
''' '''
query = query_for_course(course_key, "asset" if not get_thumbnails else "thumbnail")
find_args = {"sort": sort}
if maxresults > 0: if maxresults > 0:
items = self.fs_files.find( find_args.update({
query_for_course(course_key, "asset" if not get_thumbnails else "thumbnail"), "skip": start,
skip=start, limit=maxresults, sort=sort "limit": maxresults,
) })
else: if filter_params:
items = self.fs_files.find( query.update(filter_params)
query_for_course(course_key, "asset" if not get_thumbnails else "thumbnail"), sort=sort
) items = self.fs_files.find(query, **find_args)
count = items.count() count = items.count()
assets = list(items) assets = list(items)
......
...@@ -333,6 +333,10 @@ class TestMongoModuleStore(TestMongoModuleStoreBase): ...@@ -333,6 +333,10 @@ class TestMongoModuleStore(TestMongoModuleStoreBase):
location = Location('edX', 'toy', '2012_Fall', 'course', '2012_Fall') location = Location('edX', 'toy', '2012_Fall', 'course', '2012_Fall')
course_content, __ = self.content_store.get_all_content_for_course(location.course_key) course_content, __ = self.content_store.get_all_content_for_course(location.course_key)
assert_true(len(course_content) > 0) assert_true(len(course_content) > 0)
filter_params = _build_requested_filter('Images')
filtered_course_content, __ = self.content_store.get_all_content_for_course(
location.course_key, filter_params=filter_params)
assert_true(len(filtered_course_content) < len(course_content))
# a bit overkill, could just do for content[0] # a bit overkill, could just do for content[0]
for content in course_content: for content in course_content:
assert not content.get('locked', False) assert not content.get('locked', False)
...@@ -778,3 +782,35 @@ class TestMongoKeyValueStore(object): ...@@ -778,3 +782,35 @@ class TestMongoKeyValueStore(object):
for scope in (Scope.preferences, Scope.user_info, Scope.user_state, Scope.parent): for scope in (Scope.preferences, Scope.user_info, Scope.user_state, Scope.parent):
with assert_raises(InvalidScopeError): with assert_raises(InvalidScopeError):
self.kvs.delete(KeyValueStore.Key(scope, None, None, 'foo')) self.kvs.delete(KeyValueStore.Key(scope, None, None, 'foo'))
def _build_requested_filter(requested_filter):
"""
Returns requested filter_params string.
"""
# Files and Uploads type filter values
all_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',
],
}
requested_file_types = all_filters.get(requested_filter, None)
where = ["JSON.stringify(this.contentType).toUpperCase() == JSON.stringify('{}').toUpperCase()".format(
req_filter) for req_filter in requested_file_types]
filter_params = {
"$where": ' || '.join(where),
}
return filter_params
...@@ -2,15 +2,84 @@ ...@@ -2,15 +2,84 @@
The Files and Uploads page for a course in Studio The Files and Uploads page for a course in Studio
""" """
import urllib
import os
from opaque_keys.edx.locator import CourseLocator
from . import BASE_URL
from .course_page import CoursePage from .course_page import CoursePage
from bok_choy.javascript import wait_for_js, requirejs
@requirejs('js/views/assets')
class AssetIndexPage(CoursePage): class AssetIndexPage(CoursePage):
""" """
The Files and Uploads page for a course in Studio The Files and Uploads page for a course in Studio
""" """
url_path = "assets" url_path = "assets"
type_filter_element = '#js-asset-type-col'
@property
def url(self):
"""
Construct a URL to the page within the course.
"""
# TODO - is there a better way to make this agnostic to the underlying default module store?
default_store = os.environ.get('DEFAULT_STORE', 'draft')
course_key = CourseLocator(
self.course_info['course_org'],
self.course_info['course_num'],
self.course_info['course_run'],
deprecated=(default_store == 'draft')
)
url = "/".join([BASE_URL, self.url_path, urllib.quote_plus(unicode(course_key))])
return url if url[-1] is '/' else url + '/'
@wait_for_js
def is_browser_on_page(self): def is_browser_on_page(self):
return self.q(css='body.view-uploads').present return self.q(css='body.view-uploads').present
@wait_for_js
def type_filter_on_page(self):
"""
Checks that type filter is in table header.
"""
return self.q(css=self.type_filter_element).present
@wait_for_js
def type_filter_header_label_visible(self):
"""
Checks type filter label is added and visible in the pagination header.
"""
return self.q(css='span.filter-column').visible
@wait_for_js
def click_type_filter(self):
"""
Clicks type filter menu.
"""
self.q(css=".filterable-column .nav-item").click()
@wait_for_js
def select_type_filter(self, filter_number):
"""
Selects Type filter from dropdown which filters the results.
Returns False if no filter.
"""
self.wait_for_ajax()
if self.q(css=".filterable-column .nav-item").is_present():
if not self.q(css=self.type_filter_element + " .wrapper-nav-sub").visible:
self.q(css=".filterable-column > .nav-item").first.click()
self.wait_for_element_visibility(
self.type_filter_element + " .wrapper-nav-sub", "Type Filter promise satisfied.")
self.q(css=self.type_filter_element + " .column-filter-link").nth(filter_number).click()
self.wait_for_ajax()
return True
return False
def return_results_set(self):
"""
Returns the asset set from the page
"""
return self.q(css="#asset-table-body tr").results
"""
Acceptance tests for Studio related to the asset index page.
"""
from ...pages.studio.asset_index import AssetIndexPage
from acceptance.tests.studio.base_studio_test import StudioCourseTest
from acceptance.fixtures.base import StudioApiLoginError
class AssetIndexTest(StudioCourseTest):
"""
Tests for the Asset index page.
"""
def setUp(self, is_staff=False):
super(AssetIndexTest, self).setUp()
self.asset_page = AssetIndexPage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
def populate_course_fixture(self, course_fixture):
"""
Populate the children of the test course fixture.
"""
self.course_fixture.add_asset(['image.jpg', 'textbook.pdf'])
def test_page_existence(self):
"""
Make sure that the page is accessible.
"""
self.asset_page.visit()
def test_type_filter_exists(self):
"""
Make sure type filter is on the page.
"""
self.asset_page.visit()
assert self.asset_page.type_filter_on_page() is True
def test_filter_results(self):
"""
Make sure type filter actually filters the results.
"""
self.asset_page.visit()
all_results = len(self.asset_page.return_results_set())
if self.asset_page.select_type_filter(1):
filtered_results = len(self.asset_page.return_results_set())
assert self.asset_page.type_filter_header_label_visible()
assert all_results > filtered_results
else:
msg = "Could not open select Type filter"
raise StudioApiLoginError(msg)
...@@ -51,6 +51,7 @@ class LibraryEditPageTest(StudioLibraryTest): ...@@ -51,6 +51,7 @@ class LibraryEditPageTest(StudioLibraryTest):
Then one XBlock is displayed Then one XBlock is displayed
And displayed XBlock are second one And displayed XBlock are second one
""" """
self.browser.save_screenshot('library_page')
self.assertEqual(len(self.lib_page.xblocks), 0) self.assertEqual(len(self.lib_page.xblocks), 0)
# Create a new block: # Create a new block:
......
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