diff --git a/cms/djangoapps/contentstore/views/videos.py b/cms/djangoapps/contentstore/views/videos.py index 4b77bc619c..c9e5c1f0e9 100644 --- a/cms/djangoapps/contentstore/views/videos.py +++ b/cms/djangoapps/contentstore/views/videos.py @@ -423,7 +423,14 @@ def videos_index_html(course): 'previous_uploads': _get_index_videos(course), 'concurrent_upload_limit': settings.VIDEO_UPLOAD_PIPELINE.get('CONCURRENT_UPLOAD_LIMIT', 0), 'video_supported_file_formats': VIDEO_SUPPORTED_FILE_FORMATS.keys(), - 'video_upload_max_file_size': VIDEO_UPLOAD_MAX_FILE_SIZE_GB + 'video_upload_max_file_size': VIDEO_UPLOAD_MAX_FILE_SIZE_GB, + 'video_image_settings': { + 'max_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MAX_BYTES'], + 'min_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES'], + 'max_width': settings.VIDEO_IMAGE_MAX_WIDTH, + 'max_height': settings.VIDEO_IMAGE_MAX_HEIGHT, + 'supported_file_formats': settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS + } } ) diff --git a/cms/static/js/factories/videos_index.js b/cms/static/js/factories/videos_index.js index 07a193e7bb..f3e735fba7 100644 --- a/cms/static/js/factories/videos_index.js +++ b/cms/static/js/factories/videos_index.js @@ -13,7 +13,8 @@ define([ uploadButton, previousUploads, videoSupportedFileFormats, - videoUploadMaxFileSizeInGB + videoUploadMaxFileSizeInGB, + videoImageSettings ) { var activeView = new ActiveVideoUploadListView({ postUrl: videoHandlerUrl, @@ -21,6 +22,7 @@ define([ uploadButton: uploadButton, videoSupportedFileFormats: videoSupportedFileFormats, videoUploadMaxFileSizeInGB: videoUploadMaxFileSizeInGB, + videoImageSettings: videoImageSettings, onFileUploadDone: function(activeVideos) { $.ajax({ url: videoHandlerUrl, @@ -40,7 +42,8 @@ define([ defaultVideoImageURL: defaultVideoImageURL, videoHandlerUrl: videoHandlerUrl, collection: updatedCollection, - encodingsDownloadUrl: encodingsDownloadUrl + encodingsDownloadUrl: encodingsDownloadUrl, + videoImageSettings: videoImageSettings }); $contentWrapper.find('.wrapper-assets').replaceWith(updatedView.render().$el); }); @@ -51,7 +54,8 @@ define([ defaultVideoImageURL: defaultVideoImageURL, videoHandlerUrl: videoHandlerUrl, collection: new Backbone.Collection(previousUploads), - encodingsDownloadUrl: encodingsDownloadUrl + encodingsDownloadUrl: encodingsDownloadUrl, + videoImageSettings: videoImageSettings }); $contentWrapper.append(activeView.render().$el); $contentWrapper.append(previousView.render().$el); diff --git a/cms/static/js/spec/views/previous_video_upload_list_spec.js b/cms/static/js/spec/views/previous_video_upload_list_spec.js index bb89be22e7..0cfa18b15c 100644 --- a/cms/static/js/spec/views/previous_video_upload_list_spec.js +++ b/cms/static/js/spec/views/previous_video_upload_list_spec.js @@ -25,7 +25,8 @@ define( ); var view = new PreviousVideoUploadListView({ collection: collection, - videoHandlerUrl: videoHandlerUrl + videoHandlerUrl: videoHandlerUrl, + videoImageSettings: {} }); return view.render().$el; }, diff --git a/cms/static/js/spec/views/previous_video_upload_spec.js b/cms/static/js/spec/views/previous_video_upload_spec.js index 2b0c164863..6698b2dda3 100644 --- a/cms/static/js/spec/views/previous_video_upload_spec.js +++ b/cms/static/js/spec/views/previous_video_upload_spec.js @@ -14,7 +14,8 @@ define( }, view = new PreviousVideoUploadView({ model: new Backbone.Model($.extend({}, defaultData, modelData)), - videoHandlerUrl: '/videos/course-v1:org.0+course_0+Run_0' + videoHandlerUrl: '/videos/course-v1:org.0+course_0+Run_0', + videoImageSettings: {} }); return view.render().$el; }; diff --git a/cms/static/js/spec/views/video_thumbnail_spec.js b/cms/static/js/spec/views/video_thumbnail_spec.js index c62eae63de..4cdb8b086c 100644 --- a/cms/static/js/spec/views/video_thumbnail_spec.js +++ b/cms/static/js/spec/views/video_thumbnail_spec.js @@ -1,36 +1,77 @@ define( ['jquery', 'underscore', 'backbone', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'js/views/video_thumbnail', 'common/js/spec_helpers/template_helpers'], - function($, _, Backbone, AjaxHelpers, VideoThumbnailView, TemplateHelpers) { + 'js/views/video_thumbnail', 'js/views/previous_video_upload_list', 'common/js/spec_helpers/template_helpers'], + function($, _, Backbone, AjaxHelpers, VideoThumbnailView, PreviousVideoUploadListView, TemplateHelpers) { 'use strict'; describe('VideoThumbnailView', function() { var IMAGE_UPLOAD_URL = '/videos/upload/image', UPLOADED_IMAGE_URL = 'images/upload_success.jpg', + VIDEO_IMAGE_MAX_BYTES = 2 * 1024 * 1024, + VIDEO_IMAGE_MIN_BYTES = 2 * 1024, + VIDEO_IMAGE_MAX_WIDTH = 1280, + VIDEO_IMAGE_MAX_HEIGHT = 720, + VIDEO_IMAGE_SUPPORTED_FILE_FORMATS = { + '.bmp': 'image/bmp', + '.bmp2': 'image/x-ms-bmp', // PIL gives x-ms-bmp format + '.gif': 'image/gif', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png' + }, + videoListView, videoThumbnailView, + $videoListEl, + $videoThumbnailEl, + createVideoListView, createFakeImageFile, - verifyStateInfo, - render = function(modelData) { - var defaultData = { + verifyStateInfo; + + /** + * Creates a list of video records. + * + * @param {Object} modelData Model data for video records. + * @param {Integer} numVideos Number of video elements to create. + * @param {Integer} videoViewIndex Index of video on which videoThumbnailView would be based. + */ + createVideoListView = function(modelData, numVideos, videoViewIndex) { + var modelData = modelData || {}, // eslint-disable-line no-redeclare + numVideos = numVideos || 1, // eslint-disable-line no-redeclare + videoViewIndex = videoViewIndex || 0, // eslint-disable-line no-redeclare, + defaultData = { client_video_id: 'foo.mp4', duration: 42, created: '2014-11-25T23:13:05', edx_video_id: 'dummy_id', - status: 'uploading', - thumbnail_url: null - }; - videoThumbnailView = new VideoThumbnailView({ - model: new Backbone.Model($.extend({}, defaultData, modelData)), - imageUploadURL: IMAGE_UPLOAD_URL - }); - return videoThumbnailView.render().$el; - }; + status: 'uploading' + }, + collection = new Backbone.Collection(_.map(_.range(numVideos), function(num, index) { + return new Backbone.Model( + _.extend({}, defaultData, {edx_video_id: 'dummy_id_' + index}, modelData) + ); + })); + videoListView = new PreviousVideoUploadListView({ + collection: collection, + videoHandlerUrl: '/videos/course-v1:org.0+course_0+Run_0', + videoImageUploadURL: IMAGE_UPLOAD_URL, + videoImageSettings: { + max_size: VIDEO_IMAGE_MAX_BYTES, + min_size: VIDEO_IMAGE_MIN_BYTES, + max_width: VIDEO_IMAGE_MAX_WIDTH, + max_height: VIDEO_IMAGE_MAX_HEIGHT, + supported_file_formats: VIDEO_IMAGE_SUPPORTED_FILE_FORMATS + } + }); + $videoListEl = videoListView.render().$el; + videoThumbnailView = videoListView.itemViews[videoViewIndex].videoThumbnailView; + $videoThumbnailEl = videoThumbnailView.render().$el; + return videoListView; + }; - createFakeImageFile = function(size) { - var fileFakeData = 'i63ljc6giwoskyb9x5sw0169bdcmcxr3cdz8boqv0lik971972cmd6yknvcxr5sw0nvc169bdcmcxsdf'; - return new Blob( - [fileFakeData.substr(0, size)], - {type: 'image/jpg'} - ); + + createFakeImageFile = function(size, type) { + var size = size || VIDEO_IMAGE_MIN_BYTES, // eslint-disable-line no-redeclare + type = type || 'image/jpeg'; // eslint-disable-line no-redeclare + return new Blob([Array(size + 1).join('i')], {type: type}); }; verifyStateInfo = function($thumbnail, state, onHover, additionalSRText) { @@ -50,9 +91,12 @@ define( ).toEqual(additionalSRText); } - expect($thumbnail.find('.action-icon').html().trim()).toEqual( - videoThumbnailView.actionsInfo[state].icon - ); + if (state !== 'error') { + expect($thumbnail.find('.action-icon').html().trim()).toEqual( + videoThumbnailView.actionsInfo[state].icon + ); + } + expect($thumbnail.find('.action-text').html().trim()).toEqual( videoThumbnailView.actionsInfo[state].text ); @@ -68,22 +112,22 @@ define( beforeEach(function() { setFixtures('
'); TemplateHelpers.installTemplate('video-thumbnail'); + TemplateHelpers.installTemplate('previous-video-upload-list'); + createVideoListView(); }); it('renders as expected', function() { - var $el = render({}); - expect($el.find('.thumbnail-wrapper')).toExist(); - expect($el.find('.upload-image-input')).toExist(); + expect($videoThumbnailEl.find('.thumbnail-wrapper')).toExist(); + expect($videoThumbnailEl.find('.upload-image-input')).toExist(); }); it('does not show duration if not available', function() { - var $el = render({duration: 0}); - expect($el.find('.thumbnail-wrapper .video-duration')).not.toExist(); + createVideoListView({duration: 0}); + expect($videoThumbnailEl.find('.thumbnail-wrapper .video-duration')).not.toExist(); }); it('shows the duration if available', function() { - var $el = render({}), - $duration = $el.find('.thumbnail-wrapper .video-duration'); + var $duration = $videoThumbnailEl.find('.thumbnail-wrapper .video-duration'); expect($duration).toExist(); expect($duration.find('.duration-text-machine').text().trim()).toEqual('0:42'); expect($duration.find('.duration-text-human').text().trim()).toEqual('Video duration is 42 seconds'); @@ -114,8 +158,8 @@ define( }); it('can upload image', function() { - var $el = render({}), - $thumbnail = $el.find('.thumbnail-wrapper'), + var videoViewIndex = 0, + $thumbnail = $videoThumbnailEl.find('.thumbnail-wrapper'), requests = AjaxHelpers.requests(this), additionalSRText = videoThumbnailView.getSRText(); @@ -125,12 +169,17 @@ define( verifyStateInfo($thumbnail, 'requirements', true, additionalSRText); // Add image to upload queue and send POST request to upload image - $el.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile(60)]}); + $videoThumbnailEl.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile()]}); verifyStateInfo($thumbnail, 'progress'); // Verify if POST request received for image upload - AjaxHelpers.expectRequest(requests, 'POST', IMAGE_UPLOAD_URL + '/dummy_id', new FormData()); + AjaxHelpers.expectRequest( + requests, + 'POST', + IMAGE_UPLOAD_URL + '/dummy_id_' + videoViewIndex, + new FormData() + ); // Send successful upload response AjaxHelpers.respondWithJson(requests, {image_url: UPLOADED_IMAGE_URL}); @@ -142,45 +191,136 @@ define( }); it('shows error state correctly', function() { - var $el = render({}), - $thumbnail = $el.find('.thumbnail-wrapper'), + var $thumbnail = $videoThumbnailEl.find('.thumbnail-wrapper'), requests = AjaxHelpers.requests(this); videoThumbnailView.chooseFile(); // Add image to upload queue and send POST request to upload image - $el.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile(60)]}); + $videoThumbnailEl.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile()]}); AjaxHelpers.respondWithError(requests, 400); verifyStateInfo($thumbnail, 'error'); }); - it('should show error notification in case of server error', function() { - var $el = render({}), - requests = AjaxHelpers.requests(this); + it('calls readMessage with correct message', function() { + var errorMessage = 'This image file type is not supported. Supported file types are ' + + videoThumbnailView.getVideoImageSupportedFileFormats().humanize + '.', + successData = { + files: [createFakeImageFile()], + submit: function() {} + }, + failureData = { + jqXHR: { + responseText: JSON.stringify({ + error: errorMessage + }) + } + }; + + spyOn(videoThumbnailView, 'readMessages'); + + videoThumbnailView.imageSelected({}, successData); + expect(videoThumbnailView.readMessages).toHaveBeenCalledWith(['Video image upload started']); + videoThumbnailView.imageUploadSucceeded({}, {result: {image_url: UPLOADED_IMAGE_URL}}); + expect(videoThumbnailView.readMessages).toHaveBeenCalledWith(['Video image upload completed']); + videoThumbnailView.imageUploadFailed({}, failureData); + expect(videoThumbnailView.readMessages).toHaveBeenCalledWith( + ['Could not upload the video image file', errorMessage] + ); + }); + + it('should show error message in case of server error', function() { + var requests = AjaxHelpers.requests(this); videoThumbnailView.chooseFile(); // Add image to upload queue and send POST request to upload image - $el.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile(60)]}); + $videoThumbnailEl.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile()]}); AjaxHelpers.respondWithError(requests); - expect($('#notification-error-title').text().trim()).toEqual( - "Studio's having trouble saving your work" + // Verify error message is present + expect($videoListEl.find('.thumbnail-error-wrapper')).toExist(); + }); + + it('should show error message when file is smaller than minimum size', function() { + videoThumbnailView.chooseFile(); + + // Add image to upload queue and send POST request to upload image + $videoThumbnailEl.find('.upload-image-input') + .fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MIN_BYTES - 10)]}); + + // Verify error message + expect($videoListEl.find('.thumbnail-error-wrapper').find('.action-text').html() + .trim()).toEqual( + 'The selected image must be larger than ' + + videoThumbnailView.getVideoImageMinSize().humanize + '.' ); }); - it('calls readMessage with correct message', function() { - spyOn(videoThumbnailView, 'readMessages'); + it('should show error message when file is larger than maximum size', function() { + videoThumbnailView.chooseFile(); - videoThumbnailView.imageSelected({}, {submit: function() {}}); - expect(videoThumbnailView.readMessages).toHaveBeenCalledWith(['Video image upload started']); - videoThumbnailView.imageUploadSucceeded({}, {result: {image_url: UPLOADED_IMAGE_URL}}); - expect(videoThumbnailView.readMessages).toHaveBeenCalledWith(['Video image upload completed']); - videoThumbnailView.imageUploadFailed(); - expect(videoThumbnailView.readMessages).toHaveBeenCalledWith(['Video image upload failed']); + // Add image to upload queue and send POST request to upload image + $videoThumbnailEl.find('.upload-image-input') + .fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MAX_BYTES + 10)]}); + + // Verify error message + expect($videoListEl.find('.thumbnail-error-wrapper').find('.action-text').html() + .trim()).toEqual( + 'The selected image must be smaller than ' + + videoThumbnailView.getVideoImageMaxSize().humanize + '.' + ); + }); + + it('should not show error message when file size is equals to minimum file size', function() { + videoThumbnailView.chooseFile(); + + // Add image to upload queue and send POST request to upload image + $videoThumbnailEl.find('.upload-image-input') + .fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MIN_BYTES)]}); + + // Verify error not present. + expect($videoListEl.find('.thumbnail-error-wrapper')).not.toExist(); + }); + + it('should not show error message when file size is equals to maximum file size', function() { + videoThumbnailView.chooseFile(); + + // Add image to upload queue and send POST request to upload image + $videoThumbnailEl.find('.upload-image-input') + .fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MAX_BYTES)]}); + + // Verify error not present. + expect($videoListEl.find('.thumbnail-error-wrapper')).not.toExist(); + }); + + it('should show error message when file has unsupported content type', function() { + videoThumbnailView.chooseFile(); + + // Add image to upload queue and send POST request to upload image + $videoThumbnailEl.find('.upload-image-input') + .fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MIN_BYTES, 'mov/mp4')]}); + + // Verify error message + expect($videoListEl.find('.thumbnail-error-wrapper').find('.action-text').html() + .trim()).toEqual( + 'This image file type is not supported. Supported file types are ' + + videoThumbnailView.getVideoImageSupportedFileFormats().humanize + '.' + ); + }); + + it('should not show error message when file has supported content type', function() { + videoThumbnailView.chooseFile(); + + // Add image to upload queue and send POST request to upload image + $videoThumbnailEl.find('.upload-image-input') + .fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MIN_BYTES)]}); + + // Verify error message is not present + expect($videoListEl.find('.thumbnail-error-wrapper')).not.toExist(); }); }); } diff --git a/cms/static/js/views/previous_video_upload.js b/cms/static/js/views/previous_video_upload.js index bdea422af1..7961dc196d 100644 --- a/cms/static/js/views/previous_video_upload.js +++ b/cms/static/js/views/previous_video_upload.js @@ -20,7 +20,8 @@ define( this.videoThumbnailView = new VideoThumbnailView({ model: this.model, imageUploadURL: options.videoImageUploadURL, - defaultVideoImageURL: options.defaultVideoImageURL + defaultVideoImageURL: options.defaultVideoImageURL, + videoImageSettings: options.videoImageSettings }); }, diff --git a/cms/static/js/views/previous_video_upload_list.js b/cms/static/js/views/previous_video_upload_list.js index 31e9b543f6..c842a2a3c8 100644 --- a/cms/static/js/views/previous_video_upload_list.js +++ b/cms/static/js/views/previous_video_upload_list.js @@ -14,6 +14,7 @@ define( videoImageUploadURL: options.videoImageUploadURL, defaultVideoImageURL: options.defaultVideoImageURL, videoHandlerUrl: options.videoHandlerUrl, + videoImageSettings: options.videoImageSettings, model: model }); }); diff --git a/cms/static/js/views/video_thumbnail.js b/cms/static/js/views/video_thumbnail.js index ee94c27978..40c08a4e45 100644 --- a/cms/static/js/views/video_thumbnail.js +++ b/cms/static/js/views/video_thumbnail.js @@ -1,41 +1,14 @@ define( ['underscore', 'gettext', 'moment', 'js/utils/date_utils', 'js/views/baseview', 'common/js/components/utils/view_utils', 'edx-ui-toolkit/js/utils/html-utils', - 'edx-ui-toolkit/js/utils/string-utils', 'text!templates/video-thumbnail.underscore'], - function(_, gettext, moment, DateUtils, BaseView, ViewUtils, HtmlUtils, StringUtils, VideoThumbnailTemplate) { + 'edx-ui-toolkit/js/utils/string-utils', 'text!templates/video-thumbnail.underscore', + 'text!templates/video-thumbnail-error.underscore'], + function(_, gettext, moment, DateUtils, BaseView, ViewUtils, HtmlUtils, StringUtils, VideoThumbnailTemplate, + VideoThumbnailErrorTemplate) { 'use strict'; var VideoThumbnailView = BaseView.extend({ - actionsInfo: { - upload: { - icon: '', - text: gettext('Add Thumbnail') - }, - edit: { - icon: '', - text: gettext('Edit Thumbnail') - }, - error: { - icon: '', - text: gettext('Image upload failed') - }, - progress: { - icon: '', - text: gettext('Uploading') - }, - requirements: { - icon: '', - text: HtmlUtils.interpolateHtml( - // Translators: This is a 3 part text which tells the image requirements. - gettext('Image requirements: {lineBreak} 1280px by 720px {lineBreak} .jpg, .png, or .gif'), - { - lineBreak: HtmlUtils.HTML('
') - } - ).toString() - } - }, - events: { 'click .thumbnail-wrapper': 'chooseFile', 'mouseover .thumbnail-wrapper': 'showHoverState', @@ -46,9 +19,45 @@ define( initialize: function(options) { this.template = HtmlUtils.template(VideoThumbnailTemplate); + this.thumbnailErrorTemplate = HtmlUtils.template(VideoThumbnailErrorTemplate); this.imageUploadURL = options.imageUploadURL; this.defaultVideoImageURL = options.defaultVideoImageURL; this.action = this.model.get('course_video_image_url') ? 'edit' : 'upload'; + this.videoImageSettings = options.videoImageSettings; + this.actionsInfo = { + upload: { + icon: '', + text: gettext('Add Thumbnail') + }, + edit: { + icon: '', + text: gettext('Edit Thumbnail') + }, + error: { + icon: '', + text: gettext('Image upload failed') + }, + progress: { + icon: '', + text: gettext('Uploading') + }, + requirements: { + icon: '', + text: HtmlUtils.interpolateHtml( + // Translators: This is a 3 part text which tells the image requirements. + gettext('{ReqTextSpanStart}Image requirements{spanEnd}{lineBreak}{InstructionsSpanStart}{videoImageResoultion}{lineBreak} {videoImageSupportedFileFormats}{spanEnd}'), // eslint-disable-line max-len + { + videoImageResoultion: this.getVideoImageResolution(), + videoImageSupportedFileFormats: this.getVideoImageSupportedFileFormats().humanize, + lineBreak: HtmlUtils.HTML('
'), + ReqTextSpanStart: HtmlUtils.HTML(''), + InstructionsSpanStart: HtmlUtils.HTML(''), + spanEnd: HtmlUtils.HTML('') + } + ).toString() + } + }; + _.bindAll( this, 'render', 'chooseFile', 'imageSelected', 'imageUploadSucceeded', 'imageUploadFailed', 'showHoverState', 'hideHoverState' @@ -64,13 +73,49 @@ define( videoId: this.model.get('edx_video_id'), actionInfo: this.actionsInfo[this.action], thumbnailURL: this.model.get('course_video_image_url') || this.defaultVideoImageURL, - duration: this.getDuration(this.model.get('duration')) + duration: this.getDuration(this.model.get('duration')), + videoImageSupportedFileFormats: this.getVideoImageSupportedFileFormats(), + videoImageMaxSize: this.getVideoImageMaxSize(), + videoImageResolution: this.getVideoImageResolution() }) ); this.hideHoverState(); return this; }, + getVideoImageSupportedFileFormats: function() { + var supportedFormats = _.reject(_.keys(this.videoImageSettings.supported_file_formats), function(item) { + // Don't show redundant extensions to end user. + return item === '.bmp2' || item === '.jpeg'; + }).sort(); + return { + humanize: supportedFormats.slice(0, -1).join(', ') + ' or ' + supportedFormats.slice(-1), + machine: _.values(this.videoImageSettings.supported_file_formats) + }; + }, + + getVideoImageMaxSize: function() { + return { + humanize: this.videoImageSettings.max_size / (1024 * 1024) + ' MB', + machine: this.videoImageSettings.max_size + }; + }, + + getVideoImageMinSize: function() { + return { + humanize: this.videoImageSettings.min_size / 1024 + ' KB', + machine: this.videoImageSettings.min_size + }; + }, + + getVideoImageResolution: function() { + return StringUtils.interpolate( + // Translators: message will be like 1280x720 pixels + gettext('{maxWidth}x{maxHeight} pixels'), + {maxWidth: this.videoImageSettings.max_width, maxHeight: this.videoImageSettings.max_height} + ); + }, + getImageAltText: function() { return StringUtils.interpolate( // Translators: message will be like Thumbnail for Arrow.mp4 @@ -162,9 +207,20 @@ define( }, imageSelected: function(event, data) { - this.readMessages([gettext('Video image upload started')]); - this.showUploadInProgressMessage(); - data.submit(); + var errorMessage; + // If an error is already present above the video element, remove it. + this.clearErrorMessage(this.model.get('edx_video_id')); + + errorMessage = this.validateImageFile(data.files[0]); + if (!errorMessage) { + // Do not trigger global AJAX error handler + data.global = false; // eslint-disable-line no-param-reassign + this.readMessages([gettext('Video image upload started')]); + this.showUploadInProgressMessage(); + data.submit(); + } else { + this.showErrorMessage(errorMessage); + } }, imageUploadSucceeded: function(event, data) { @@ -174,10 +230,9 @@ define( this.readMessages([gettext('Video image upload completed')]); }, - imageUploadFailed: function() { - this.action = 'error'; - this.setActionInfo(this.action, true); - this.readMessages([gettext('Video image upload failed')]); + imageUploadFailed: function(event, data) { + var errorText = JSON.parse(data.jqXHR.responseText).error; + this.showErrorMessage(errorText); }, showUploadInProgressMessage: function() { @@ -191,7 +246,12 @@ define( } else if (this.action === 'edit') { this.setActionInfo(this.action, true); } - this.$('.thumbnail-wrapper').addClass('focused'); + + // When we had error, focused effect was not wearing off after hover out. + // Add focused class to all rows except rows having error. + if (!$(this.$el.parent()).hasClass('has-thumbnail-error')) { + this.$('.thumbnail-wrapper').addClass('focused'); + } }, hideHoverState: function() { @@ -204,10 +264,19 @@ define( setActionInfo: function(action, showText, additionalSRText) { this.$('.thumbnail-action').toggle(showText); - HtmlUtils.setHtml( - this.$('.thumbnail-action .action-icon'), - HtmlUtils.HTML(this.actionsInfo[action].icon) - ); + + // In case of error, we don't want to show any icon on the image. + if (action === 'error') { + HtmlUtils.setHtml( + this.$('.thumbnail-action .action-icon'), + HtmlUtils.HTML('') + ); + } else { + HtmlUtils.setHtml( + this.$('.thumbnail-action .action-icon'), + HtmlUtils.HTML(this.actionsInfo[action].icon) + ); + } HtmlUtils.setHtml( this.$('.thumbnail-action .action-text'), HtmlUtils.HTML(this.actionsInfo[action].text) @@ -216,6 +285,137 @@ define( this.$('.thumbnail-wrapper').attr('class', 'thumbnail-wrapper {action}'.replace('{action}', action)); }, + validateImageFile: function(imageFile) { + var errorMessage = ''; + + if (!_.contains(this.getVideoImageSupportedFileFormats().machine, imageFile.type)) { + errorMessage = StringUtils.interpolate( + // Translators: supportedFileFormats will be like .bmp, gif, .jpg or .png. + gettext( + 'This image file type is not supported. Supported file types are {supportedFileFormats}.' + ), + { + supportedFileFormats: this.getVideoImageSupportedFileFormats().humanize + } + ); + } else if (imageFile.size > this.getVideoImageMaxSize().machine) { + errorMessage = StringUtils.interpolate( + // Translators: maxFileSizeInMB will be like 2 MB. + gettext('The selected image must be smaller than {maxFileSizeInMB}.'), + { + maxFileSizeInMB: this.getVideoImageMaxSize().humanize + } + ); + } else if (imageFile.size < this.getVideoImageMinSize().machine) { + errorMessage = StringUtils.interpolate( + // Translators: minFileSizeInKB will be like 2 KB. + gettext('The selected image must be larger than {minFileSizeInKB}.'), + { + minFileSizeInKB: this.getVideoImageMinSize().humanize + } + ); + } + + return errorMessage; + }, + + clearErrorMessage: function(videoId) { + var $thumbnailWrapperEl = $('.thumbnail-error-wrapper[data-video-id="' + videoId + '"]'); + if ($thumbnailWrapperEl.length) { + $thumbnailWrapperEl.remove(); + } + }, + + showErrorMessage: function(errorText) { + var videoId = this.model.get('edx_video_id'), + $parentRowEl = $(this.$el.parent()); + + this.action = 'error'; + this.setActionInfo(this.action, true); + this.readMessages([gettext('Could not upload the video image file'), errorText]); + + // Add css classes so as to distinguish. + $parentRowEl.addClass('has-thumbnail-error thumbnail-error'); + + // We need to update data attr in DOM too so as to find our element on hover. + $parentRowEl.attr('data-video-id', videoId); + + // Add error wrapper html before current video element row. + $parentRowEl.before( // safe-lint: disable=javascript-jquery-insertion + HtmlUtils.ensureHtml( + this.thumbnailErrorTemplate({videoId: videoId, errorText: errorText}) + ).toString() + ); + + // We need to treat error and error throwing row as one. + // Refresh table rows to reflect error row. + this.refreshVideoTableRowClasses(); + + // To treat current row and it's error row as one on hover, + // we add hover effect to both rows, even if it is hovered on only one row, thus, giving us + // the combined one row feel. + $('.thumbnail-error[data-video-id="' + videoId + '"]').hover(function() { + $('.thumbnail-error[data-video-id="' + videoId + '"]').toggleClass('blue-l5'); + }); + }, + + /* + Refresh video table classes. + + This method treats row and their corresponsing error rows as one, for that to achieve we need to reset + table row even odd colors. + */ + refreshVideoTableRowClasses: function() { + var savedClass, // this class will be applied to the row corresponding to error row. + oddRowClass = 'white', + evenRowClass = 'gray-l6'; + + $('.view-video-uploads .assets-table .js-table-body tr').each(function(index) { + var currentRowClass; + + // Decide current iterated row is even or odd. + if (index % 2 === 0) { + currentRowClass = evenRowClass; + } else { + currentRowClass = oddRowClass; + } + + // If the row is error row, save it's class so that it can be applied to it's corresponding row. + if ($(this).hasClass('thumbnail-error-wrapper')) { + savedClass = currentRowClass; + } + + // If current iterated row is the row which generated error + // Apply the class same as it's corresponding error row. The class was saved. + if ($(this).hasClass('has-thumbnail-error')) { + // First remove previously added classes. + $(this).removeClass(evenRowClass); + $(this).removeClass(oddRowClass); + + // Apply new class now. + $(this).addClass(savedClass); + + // Now after the saved class, swap even odd row classes. + if (currentRowClass === oddRowClass) { + oddRowClass = evenRowClass; + evenRowClass = currentRowClass; + } else { + evenRowClass = oddRowClass; + oddRowClass = currentRowClass; + } + // Reset the saved class after it has been applied. + savedClass = ''; + } else { + // For all simple rows, first remove classes if added + $(this).removeClass(evenRowClass); + $(this).removeClass(oddRowClass); + + // then add the class based on it's rows. + $(this).addClass(currentRowClass); + } + }); + }, + readMessages: function(messages) { if ($(window).prop('SR') !== undefined) { $(window).prop('SR').readTexts(messages); diff --git a/cms/static/sass/elements/_uploaded-assets.scss b/cms/static/sass/elements/_uploaded-assets.scss index 4b057aa45c..443ce72682 100644 --- a/cms/static/sass/elements/_uploaded-assets.scss +++ b/cms/static/sass/elements/_uploaded-assets.scss @@ -106,7 +106,7 @@ } &:hover { - background-color: $blue-l5; + background-color: $blue-l5 !important; .date-col, .embed-col, diff --git a/cms/static/sass/views/_video-upload.scss b/cms/static/sass/views/_video-upload.scss index af1d25081f..8c5e5054bc 100644 --- a/cms/static/sass/views/_video-upload.scss +++ b/cms/static/sass/views/_video-upload.scss @@ -169,6 +169,34 @@ } } + .gray-l6 { + background: $gray-l6 !important; + } + + .white { + background: white !important; + } + + .blue-l5 { + background: $blue-l5 !important; + } + + .thumbnail-error-wrapper { + padding: 0 !important; + border: none !important; + + .thumbnail-error-text { + color: $red; + .action-text { + margin-left: 5px; + } + } + } + + tr.has-thumbnail-error { + border: none !important; + } + $thumbnail-width: ($baseline*7.5); $thumbnail-height: ($baseline*5); @@ -192,6 +220,16 @@ } &.requirements { + .requirements-text { + font-weight: 600; + } + .requirements-instructions { + font-size: 15px; + font-family: "Open Sans"; + text-align: left; + color: #4c4c4c; + line-height: 1.5; + } .video-duration { opacity: 0; } diff --git a/cms/templates/js/video-thumbnail-error.underscore b/cms/templates/js/video-thumbnail-error.underscore new file mode 100644 index 0000000000..ef7d265a38 --- /dev/null +++ b/cms/templates/js/video-thumbnail-error.underscore @@ -0,0 +1,7 @@ + + + + <%- errorText %> + diff --git a/cms/templates/js/video-thumbnail.underscore b/cms/templates/js/video-thumbnail.underscore index dae21fe398..693bc21682 100644 --- a/cms/templates/js/video-thumbnail.underscore +++ b/cms/templates/js/video-thumbnail.underscore @@ -9,7 +9,10 @@ - <%- gettext('Recommended image resolution is 1280x720 pixels and format must be one of .jpg, .png or .gif') %> + <%- edx.StringUtils.interpolate( + gettext("Recommended image resolution is {imageResolution}, maximum image file size should be {maxFileSize} and format must be one of {supportedImageFormats}."), + {imageResolution: videoImageResolution, maxFileSize: videoImageMaxSize.humanize, supportedImageFormats: videoImageSupportedFileFormats.humanize} + ) %> <% if(duration) { %> diff --git a/cms/templates/videos_index.html b/cms/templates/videos_index.html index 0a3d54d480..94e5acb901 100644 --- a/cms/templates/videos_index.html +++ b/cms/templates/videos_index.html @@ -37,7 +37,8 @@ $(".nav-actions .upload-button"), $contentWrapper.data("previous-uploads"), ${video_supported_file_formats | n, dump_js_escaped_json}, - ${video_upload_max_file_size | n, dump_js_escaped_json} + ${video_upload_max_file_size | n, dump_js_escaped_json}, + ${video_image_settings | n, dump_js_escaped_json} ); }); diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 128795f69a..5da6cf4748 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -77,7 +77,7 @@ git+https://github.com/edx/lettuce.git@0.2.20.002#egg=lettuce==0.2.20.002 git+https://github.com/edx/edx-ora2.git@1.4.3#egg=ora2==1.4.3 -e git+https://github.com/edx/edx-submissions.git@2.0.0#egg=edx-submissions==2.0.0 git+https://github.com/edx/ease.git@release-2015-07-14#egg=ease==0.1.3 -git+https://github.com/edx/edx-val.git@0.0.14#egg=edxval==0.0.14 +git+https://github.com/edx/edx-val.git@0.0.15#egg=edxval==0.0.15 git+https://github.com/pmitros/RecommenderXBlock.git@v1.2#egg=recommender-xblock==1.2 git+https://github.com/solashirai/crowdsourcehinter.git@518605f0a95190949fe77bd39158450639e2e1dc#egg=crowdsourcehinter-xblock==0.1 -e git+https://github.com/pmitros/RateXBlock.git@367e19c0f6eac8a5f002fd0f1559555f8e74bfff#egg=rate-xblock