diff --git a/common/test/acceptance/pages/lms/learner_profile.py b/common/test/acceptance/pages/lms/learner_profile.py index 50d1a9dcb0..c32847ee52 100644 --- a/common/test/acceptance/pages/lms/learner_profile.py +++ b/common/test/acceptance/pages/lms/learner_profile.py @@ -5,6 +5,8 @@ from . import BASE_URL from bok_choy.page_object import PageObject from .fields import FieldsMixin from bok_choy.promise import EmptyPromise +from .instructor_dashboard import InstructorDashboardPage +from selenium.webdriver import ActionChains PROFILE_VISIBILITY_SELECTOR = '#u-field-select-account_privacy option[value="{}"]' @@ -165,3 +167,109 @@ class LearnerProfilePage(FieldsMixin, PageObject): """ self.wait_for_ajax() return self.q(css='#u-field-message-account_privacy').visible + + @property + def profile_has_default_image(self): + """ + Return bool if image field has default photo or not. + """ + self.wait_for_field('image') + default_links = self.q(css='.image-frame').attrs('src') + return 'default-profile' in default_links[0] if default_links else False + + def mouse_hover(self, element): + """ + Mouse over on given element. + """ + mouse_hover_action = ActionChains(self.browser).move_to_element(element) + mouse_hover_action.perform() + + def profile_has_image_with_public_access(self): + """ + Check if image is present with remove/upload access. + """ + self.wait_for_field('image') + + self.mouse_hover(self.browser.find_element_by_css_selector('.image-wrapper')) + self.wait_for_element_visibility('.u-field-upload-button', "upload button is visible") + return self.q(css='.u-field-upload-button').visible + + def profile_has_image_with_private_access(self): + """ + Check if image is present with remove/upload access. + """ + self.wait_for_field('image') + return self.q(css='.u-field-upload-button').visible + + def upload_file(self, filename): + """ + Helper method to upload an image file. + """ + self.wait_for_element_visibility('.u-field-upload-button', "upload button is visible") + file_path = InstructorDashboardPage.get_asset_path(filename) + + # make the elements visible. + self.browser.execute_script('$(".u-field-upload-button").css("opacity",1);') + self.browser.execute_script('$(".upload-button-input").css("opacity",1);') + + self.wait_for_element_visibility('.upload-button-input', "upload button is visible") + + self.browser.execute_script('$(".upload-submit").show();') + + # First send_keys will initialize the jquery auto upload plugin. + self.q(css='.upload-button-input').results[0].send_keys(file_path) + self.q(css='.upload-submit').first.click() + self.q(css='.upload-button-input').results[0].send_keys(file_path) + + self.wait_for_ajax() + + def upload_correct_image_file(self, filename): + """ + Selects the correct file and clicks the upload button. + """ + self._upload_file(filename) + + @property + def image_upload_success(self): + """ + Returns the bool, if image is updated or not. + """ + self.wait_for_field('image') + self.wait_for_ajax() + + self.wait_for_element_visibility('.image-frame', "image box is visible") + image_link = self.q(css='.image-frame').attrs('src') + return 'default-profile' not in image_link[0] + + @property + def profile_image_message(self): + """ + Returns the text message for profile image. + """ + self.wait_for_field('image') + self.wait_for_ajax() + return self.q(css='.message-banner p').text[0] + + def remove_profile_image(self): + """ + Removes the profile image. + """ + self.wait_for_field('image') + self.wait_for_ajax() + + self.wait_for_element_visibility('.image-wrapper', "remove button is visible") + self.browser.execute_script('$(".u-field-remove-button").css("opacity",1);') + self.mouse_hover(self.browser.find_element_by_css_selector('.image-wrapper')) + + self.wait_for_element_visibility('.u-field-remove-button', "remove button is visible") + self.q(css='.u-field-remove-button').first.click() + + self.mouse_hover(self.browser.find_element_by_css_selector('.image-wrapper')) + self.wait_for_element_visibility('.u-field-upload-button', "upload button is visible") + return True + + @property + def remove_link_present(self): + self.wait_for_field('image') + self.mouse_hover(self.browser.find_element_by_css_selector('.image-wrapper')) + return self.q(css='.u-field-remove-button').visible diff --git a/common/test/acceptance/tests/lms/test_learner_profile.py b/common/test/acceptance/tests/lms/test_learner_profile.py index d9cc96f3bd..43be7ef37a 100644 --- a/common/test/acceptance/tests/lms/test_learner_profile.py +++ b/common/test/acceptance/tests/lms/test_learner_profile.py @@ -111,6 +111,13 @@ class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest): self.assertEqual(profile_page.age_limit_message_present, message is not None) self.assertIn(message, profile_page.profile_forced_private_message) + def assert_default_image_has_public_access(self, profile_page): + """ + Assert that profile image has public access. + """ + self.assertTrue(profile_page.profile_has_default_image) + self.assertTrue(profile_page.profile_has_image_with_public_access()) + def test_dashboard_learner_profile_link(self): """ Scenario: Verify that my profile link is present on dashboard page and we can navigate to correct page. @@ -320,6 +327,171 @@ class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest): ) self.verify_profile_page_view_event(user_id, visibility=self.PRIVACY_PRIVATE) + def test_user_can_only_see_default_image_for_private_profile(self): + """ + Scenario: Default profile image behaves correctly for under age user. + + Given that I am on my profile page with private access + And I can see default image + When I move my cursor to the image + Then i cannot see the upload/remove image text + And i cannot upload/remove the image. + """ + year_of_birth = datetime.now().year - 5 + username, user_id = self.log_in_as_unique_user() + profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PRIVATE) + + self.verify_profile_forced_private_message( + username, + year_of_birth, + message='You must be over 13 to share a full profile.' + ) + self.assertTrue(profile_page.profile_has_default_image) + self.assertFalse(profile_page.profile_has_image_with_private_access()) + + def test_user_can_see_default_image_for_public_profile(self): + """ + Scenario: Default profile image behaves correctly for public profile. + + Given that I am on my profile page with public access + And I can see default image + When I move my cursor to the image + Then i can see the upload/remove image text + And i am able to upload new image + """ + username, user_id = self.log_in_as_unique_user() + profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC) + + self.assert_default_image_has_public_access(profile_page) + + def test_user_can_upload_the_profile_image_with_success(self): + """ + Scenario: Upload profile image works correctly. + + Given that I am on my profile page with public access + And I can see default image + When I move my cursor to the image + Then i can see the upload/remove image text + When i upload new image via file uploader + Then i can see the changed image + And i can also see the latest image after reload. + """ + username, user_id = self.log_in_as_unique_user() + profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC) + + self.assert_default_image_has_public_access(profile_page) + + profile_page.upload_file(filename='image.jpg') + self.assertTrue(profile_page.image_upload_success) + profile_page.visit() + self.assertTrue(profile_page.image_upload_success) + + def test_user_can_see_error_for_exceeding_max_file_size_limit(self): + """ + Scenario: Upload profile image does not work for > 1MB image file. + + Given that I am on my profile page with public access + And I can see default image + When I move my cursor to the image + Then i can see the upload/remove image text + When i upload new > 1MB image via file uploader + Then i can see the error message for file size limit + And i can still see the default image after page reload. + """ + username, user_id = self.log_in_as_unique_user() + profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC) + + self.assert_default_image_has_public_access(profile_page) + + profile_page.upload_file(filename='larger_image.jpg') + self.assertEqual(profile_page.profile_image_message, "Your image must be smaller than 1 MB in size.") + profile_page.visit() + self.assertTrue(profile_page.profile_has_default_image) + + def test_user_can_see_error_for_file_size_below_the_min_limit(self): + """ + Scenario: Upload profile image does not work for < 100 Bytes image file. + + Given that I am on my profile page with public access + And I can see default image + When I move my cursor to the image + Then i can see the upload/remove image text + When i upload new < 100 Bytes image via file uploader + Then i can see the error message for minimum file size limit + And i can still see the default image after page reload. + """ + username, user_id = self.log_in_as_unique_user() + profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC) + + self.assert_default_image_has_public_access(profile_page) + + profile_page.upload_file(filename='list-icon-visited.png') + self.assertEqual(profile_page.profile_image_message, "Your image must be at least 100 bytes in size.") + profile_page.visit() + self.assertTrue(profile_page.profile_has_default_image) + + def test_user_can_see_error_for_wrong_file_type(self): + """ + Scenario: Upload profile image does not work for wrong file types. + + Given that I am on my profile page with public access + And I can see default image + When I move my cursor to the image + Then i can see the upload/remove image text + When i upload new csv file via file uploader + Then i can see the error message for wrong/unsupported file type + And i can still see the default image after page reload. + """ + username, user_id = self.log_in_as_unique_user() + profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC) + + self.assert_default_image_has_public_access(profile_page) + + profile_page.upload_file(filename='cohort_users_only_username.csv') + self.assertEqual(profile_page.profile_image_message, "Unsupported file type.") + profile_page.visit() + self.assertTrue(profile_page.profile_has_default_image) + + def test_user_can_remove_profile_image(self): + """ + Scenario: Remove profile image works correctly. + + Given that I am on my profile page with public access + And I can see default image + When I move my cursor to the image + Then i can see the upload/remove image text + When i click on the remove image link + Then i can see the default image + And i can still see the default image after page reload. + """ + username, user_id = self.log_in_as_unique_user() + profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC) + + self.assert_default_image_has_public_access(profile_page) + + profile_page.upload_file(filename='image.jpg') + self.assertTrue(profile_page.image_upload_success) + self.assertTrue(profile_page.remove_profile_image()) + self.assertTrue(profile_page.profile_has_default_image) + profile_page.visit() + self.assertTrue(profile_page.profile_has_default_image) + + def test_user_cannot_remove_default_image(self): + """ + Scenario: Remove profile image does not works for default images. + + Given that I am on my profile page with public access + And I can see default image + When I move my cursor to the image + Then i can see only the upload image text + And i cannot see the remove image text + """ + username, user_id = self.log_in_as_unique_user() + profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC) + + self.assert_default_image_has_public_access(profile_page) + self.assertFalse(profile_page.remove_link_present) + class DifferentUserLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest): """ diff --git a/common/test/data/uploads/larger_image.jpg b/common/test/data/uploads/larger_image.jpg new file mode 100644 index 0000000000..18a3e1c6f9 Binary files /dev/null and b/common/test/data/uploads/larger_image.jpg differ diff --git a/common/test/data/uploads/list-icon-visited.png b/common/test/data/uploads/list-icon-visited.png new file mode 100644 index 0000000000..a3704f3b98 Binary files /dev/null and b/common/test/data/uploads/list-icon-visited.png differ diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py index 8cfd1e48d9..0070b1513f 100644 --- a/lms/envs/bok_choy.py +++ b/lms/envs/bok_choy.py @@ -131,6 +131,14 @@ MOCK_SEARCH_BACKING_FILE = ( import uuid SECRET_KEY = uuid.uuid4().hex +# Set dummy values for profile image settings. +PROFILE_IMAGE_BACKEND = { + 'class': 'storages.backends.overwrite.OverwriteStorage', + 'options': { + 'location': os.path.join(MEDIA_ROOT, 'profile-images/'), + 'base_url': os.path.join(MEDIA_URL, 'profile-images/'), + }, +} ##################################################################### # Lastly, see if the developer has any local overrides. try: diff --git a/lms/static/js/fixtures/student_profile/student_profile.html b/lms/static/js/fixtures/student_profile/student_profile.html new file mode 100644 index 0000000000..40ae15e5ba --- /dev/null +++ b/lms/static/js/fixtures/student_profile/student_profile.html @@ -0,0 +1,20 @@ +
+
+
+

+ + + + + + Loading + +

+
+ +
diff --git a/lms/static/js/spec/student_profile/learner_profile_factory_spec.js b/lms/static/js/spec/student_profile/learner_profile_factory_spec.js index a004c53aa2..e797243b26 100644 --- a/lms/static/js/spec/student_profile/learner_profile_factory_spec.js +++ b/lms/static/js/spec/student_profile/learner_profile_factory_spec.js @@ -18,7 +18,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j var requests; beforeEach(function () { - setFixtures('

Loading

'); + loadFixtures('js/fixtures/student_profile/student_profile.html'); TemplateHelpers.installTemplate('templates/fields/field_readonly'); TemplateHelpers.installTemplate('templates/fields/field_dropdown'); TemplateHelpers.installTemplate('templates/fields/field_textarea'); diff --git a/lms/static/js/spec/student_profile/learner_profile_fields_spec.js b/lms/static/js/spec/student_profile/learner_profile_fields_spec.js index cb39ae53d5..bbf289d263 100644 --- a/lms/static/js/spec/student_profile/learner_profile_fields_spec.js +++ b/lms/static/js/spec/student_profile/learner_profile_fields_spec.js @@ -10,15 +10,20 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j describe("edx.user.LearnerProfileFields", function () { - var createImageView = function (ownProfile, hasImage, imageMaxBytes, imageMinBytes, yearOfBirth) { + var MOCK_YEAR_OF_BIRTH = 1989; + var MOCK_IMAGE_MAX_BYTES = 64; + var MOCK_IMAGE_MIN_BYTES = 16; + + var createImageView = function (options) { + var yearOfBirth = _.isUndefined(options.yearOfBirth) ? MOCK_YEAR_OF_BIRTH : options.yearOfBirth; + var imageMaxBytes = _.isUndefined(options.imageMaxBytes) ? MOCK_IMAGE_MAX_BYTES : options.imageMaxBytes; + var imageMinBytes = _.isUndefined(options.imageMinBytes) ? MOCK_IMAGE_MIN_BYTES : options.imageMinBytes; var imageData = { image_url_large: '/media/profile-images/default.jpg', - has_image: hasImage ? true : false + has_image: options.hasImage ? true : false }; - yearOfBirth = _.isUndefined(yearOfBirth) ? 1989 : yearOfBirth; - var accountSettingsModel = new UserAccountModel(); accountSettingsModel.set({'profile_image': imageData}); accountSettingsModel.set({'year_of_birth': yearOfBirth}); @@ -30,15 +35,10 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j el: $('.message-banner') }); - imageMaxBytes = imageMaxBytes || 64; - imageMinBytes = imageMinBytes || 16; - - var editable = ownProfile ? 'toggle' : 'never'; - return new LearnerProfileFields.ProfileImageFieldView({ model: accountSettingsModel, valueAttribute: "profile_image", - editable: editable === 'toggle', + editable: options.ownProfile, messageView: messageView, imageMaxBytes: imageMaxBytes, imageMinBytes: imageMinBytes, @@ -48,7 +48,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j }; beforeEach(function () { - setFixtures('

Loading

'); + loadFixtures('js/fixtures/student_profile/student_profile.html'); TemplateHelpers.installTemplate('templates/student_profile/learner_profile'); TemplateHelpers.installTemplate('templates/fields/field_image'); TemplateHelpers.installTemplate("templates/fields/message_banner"); @@ -62,215 +62,230 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j ); }; - it("can upload profile image", function() { - - var imageView = createImageView(true, false); - imageView.render(); - - var requests = AjaxHelpers.requests(this); - - var imageName = 'profile_image.jpg'; - - // Initialize jquery file uploader - imageView.$('.upload-button-input').fileupload({ + var initializeUploader = function (view) { + view.$('.upload-button-input').fileupload({ url: Helpers.IMAGE_UPLOAD_API_URL, type: 'POST', - add: imageView.fileSelected, - done: imageView.imageChangeSucceeded, - fail: imageView.imageChangeFailed + add: view.fileSelected, + done: view.imageChangeSucceeded, + fail: view.imageChangeFailed + }); + }; + + describe("ProfileImageFieldView", function () { + + var verifyImageUploadButtonMessage = function (view, inProgress) { + var iconName = inProgress ? 'fa-spinner' : 'fa-camera'; + var message = inProgress ? view.titleUploading : view.uploadButtonTitle(); + expect(view.$('.upload-button-icon i').attr('class')).toContain(iconName); + expect(view.$('.upload-button-title').text().trim()).toBe(message); + }; + + var verifyImageRemoveButtonMessage = function (view, inProgress) { + var iconName = inProgress ? 'fa-spinner' : 'fa-remove'; + var message = inProgress ? view.titleRemoving : view.removeButtonTitle(); + expect(view.$('.remove-button-icon i').attr('class')).toContain(iconName); + expect(view.$('.remove-button-title').text().trim()).toBe(message); + }; + + it("can upload profile image", function() { + + var imageView = createImageView({ownProfile: true, hasImage: false}); + imageView.render(); + + var requests = AjaxHelpers.requests(this); + var imageName = 'profile_image.jpg'; + + initializeUploader(imageView); + + // Remove button should not be present for default image + expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); + + // For default image, image title should be `Upload an image` + verifyImageUploadButtonMessage(imageView, false); + + // Add image to upload queue. Validate the image size and send POST request to upload image + imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]}); + + // Verify image upload progress message + verifyImageUploadButtonMessage(imageView, true); + + // Verify if POST request received for image upload + AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_UPLOAD_API_URL, new FormData()); + + // Send 204 NO CONTENT to confirm the image upload success + AjaxHelpers.respondWithNoContent(requests); + + // Upon successful image upload, account settings model will be fetched to + // get the url for newly uploaded image, So we need to send the response for that GET + var data = {profile_image: { + image_url_large: '/media/profile-images/' + imageName, + has_image: true + }}; + AjaxHelpers.respondWithJson(requests, data); + + // Verify uploaded image name + expect(imageView.$('.image-frame').attr('src')).toContain(imageName); + + // Remove button should be present after successful image upload + expect(imageView.$('.u-field-remove-button').css('display') !== 'none').toBeTruthy(); + + // After image upload, image title should be `Change image` + verifyImageUploadButtonMessage(imageView, false); }); - // Remove button should not be present for default image - expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); + it("can remove profile image", function() { - // For default image, image title should be `Upload an image` - expect(imageView.$('.upload-button-title').text().trim()).toBe(imageView.titleAdd); + var imageView = createImageView({ownProfile: true, hasImage: false}); + imageView.render(); - // Add image to upload queue, this will validate the image size and send POST request to upload image - imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]}); + var requests = AjaxHelpers.requests(this); - // Verify image upload progress message - expect(imageView.$('.upload-button-title').text().trim()).toBe(imageView.titleUploading); + // Verify image remove title + verifyImageRemoveButtonMessage(imageView, false); - // Verify if POST request received for image upload - AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_UPLOAD_API_URL, new FormData()); + imageView.$('.u-field-remove-button').click(); - // Send 204 NO CONTENT to confirm the image upload success - AjaxHelpers.respondWithNoContent(requests); + // Verify image remove progress message + verifyImageRemoveButtonMessage(imageView, true); - // Upon successful image upload, account settings model will be fetched to get the url for newly uploaded image - // So we need to send the response for that GET - var data = {profile_image: { - image_url_large: '/media/profile-images/' + imageName, - has_image: true - }}; - AjaxHelpers.respondWithJson(requests, data); + // Verify if POST request received for image remove + AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_REMOVE_API_URL, null); - // Verify uploaded image name - expect(imageView.$('.image-frame').attr('src')).toContain(imageName); + // Send 204 NO CONTENT to confirm the image removal success + AjaxHelpers.respondWithNoContent(requests); - // Remove button should be present after successful image upload - expect(imageView.$('.u-field-remove-button').css('display') !== 'none').toBeTruthy(); + // Upon successful image removal, account settings model will be fetched to get default image url + // So we need to send the response for that GET + var data = {profile_image: { + image_url_large: '/media/profile-images/default.jpg', + has_image: false + }}; + AjaxHelpers.respondWithJson(requests, data); - // After image upload, image title should be `Change image` - expect(imageView.$('.upload-button-title').text().trim()).toBe(imageView.titleEdit); - }); - - it("can remove profile image", function() { - var imageView = createImageView(true, true); - imageView.render(); - - var requests = AjaxHelpers.requests(this); - - imageView.$('.u-field-remove-button').click(); - - // Verify image remove progress message - expect(imageView.$('.remove-button-title').text().trim()).toBe(imageView.titleRemoving); - - // Verify if POST request received for image remove - AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_REMOVE_API_URL, null); - - // Send 204 NO CONTENT to confirm the image removal success - AjaxHelpers.respondWithNoContent(requests); - - // Upon successful image removal, account settings model will be fetched to get the default image url - // So we need to send the response for that GET - var data = {profile_image: { - image_url_large: '/media/profile-images/default.jpg', - has_image: false - }}; - AjaxHelpers.respondWithJson(requests, data); - - // Remove button should not be present for default image - expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); - }); - - it("can't remove default profile image", function() { - var imageView = createImageView(true, false); - imageView.render(); - - spyOn(imageView, 'clickedRemoveButton'); - - // Remove button should not be present for default image - expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); - - imageView.$('.u-field-remove-button').click(); - - // Remove button click handler should not be called - expect(imageView.clickedRemoveButton).not.toHaveBeenCalled(); - }); - - it("can't upload image having size greater than max size", function() { - var imageView = createImageView(true, false); - imageView.render(); - - // Initialize jquery file uploader - imageView.$('.upload-button-input').fileupload({ - url: Helpers.IMAGE_UPLOAD_API_URL, - type: 'POST', - add: imageView.fileSelected, - done: imageView.imageChangeSucceeded, - fail: imageView.imageChangeFailed + // Remove button should not be present for default image + expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); }); - // Add image to upload queue, this will validate the image size - imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(70)]}); + it("can't remove default profile image", function() { - // Verify error message - expect($('.message-banner').text().trim()).toBe('Your image must be smaller than 64 Bytes in size.'); - }); + var imageView = createImageView({ownProfile: true, hasImage: false}); + imageView.render(); - it("can't upload image having size less than min size", function() { - var imageView = createImageView(true, false); - imageView.render(); + spyOn(imageView, 'clickedRemoveButton'); - // Initialize jquery file uploader - imageView.$('.upload-button-input').fileupload({ - url: Helpers.IMAGE_UPLOAD_API_URL, - type: 'POST', - add: imageView.fileSelected, - done: imageView.imageChangeSucceeded, - fail: imageView.imageChangeFailed + // Remove button should not be present for default image + expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); + + imageView.$('.u-field-remove-button').click(); + + // Remove button click handler should not be called + expect(imageView.clickedRemoveButton).not.toHaveBeenCalled(); }); - // Add image to upload queue, this will validate the image size - imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(10)]}); + it("can't upload image having size greater than max size", function() { - // Verify error message - expect($('.message-banner').text().trim()).toBe('Your image must be at least 16 Bytes in size.'); - }); + var imageView = createImageView({ownProfile: true, hasImage: false}); + imageView.render(); - it("can't upload/remove image if parental consent required", function() { - var imageView = createImageView(true, false, 64, 16, ''); - imageView.render(); + initializeUploader(imageView); - spyOn(imageView, 'clickedUploadButton'); - spyOn(imageView, 'clickedRemoveButton'); + // Add image to upload queue, this will validate the image size + imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(70)]}); - expect(imageView.$('.u-field-upload-button').css('display') === 'none').toBeTruthy(); - expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); - - imageView.$('.u-field-upload-button').click(); - imageView.$('.u-field-remove-button').click(); - - expect(imageView.clickedUploadButton).not.toHaveBeenCalled(); - expect(imageView.clickedRemoveButton).not.toHaveBeenCalled(); - }); - - it("can't upload image on others profile", function() { - var imageView = createImageView(false); - imageView.render(); - - spyOn(imageView, 'clickedUploadButton'); - spyOn(imageView, 'clickedRemoveButton'); - - expect(imageView.$('.u-field-upload-button').css('display') === 'none').toBeTruthy(); - expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); - - imageView.$('.u-field-upload-button').click(); - imageView.$('.u-field-remove-button').click(); - - expect(imageView.clickedUploadButton).not.toHaveBeenCalled(); - expect(imageView.clickedRemoveButton).not.toHaveBeenCalled(); - }); - - it("shows message if we try to navigate away during image upload/remove", function() { - var imageView = createImageView(true, false); - spyOn(imageView, 'onBeforeUnload'); - imageView.render(); - - // Initialize jquery file uploader - imageView.$('.upload-button-input').fileupload({ - url: Helpers.IMAGE_UPLOAD_API_URL, - type: 'POST', - add: imageView.fileSelected, - done: imageView.imageChangeSucceeded, - fail: imageView.imageChangeFailed + // Verify error message + expect($('.message-banner').text().trim()) + .toBe('Your image must be smaller than 64 bytes in size.'); }); - // Add image to upload queue, this will validate the image size and send POST request to upload image - imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]}); + it("can't upload image having size less than min size", function() { + var imageView = createImageView({ownProfile: true, hasImage: false}); + imageView.render(); - // Verify image upload progress message - expect(imageView.$('.upload-button-title').text().trim()).toBe(imageView.titleUploading); + initializeUploader(imageView); - $(window).trigger('beforeunload'); + // Add image to upload queue, this will validate the image size + imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(10)]}); - expect(imageView.onBeforeUnload).toHaveBeenCalled(); - }); - - it('renders message correctly', function() { - var messageSelector = '.message-banner'; - var messageView = new MessageBannerView({ - el: $(messageSelector) + // Verify error message + expect($('.message-banner').text().trim()).toBe('Your image must be at least 16 bytes in size.'); }); - messageView.showMessage('I am message view'); - // Verify error message - expect($(messageSelector).text().trim()).toBe('I am message view'); + it("can't upload and remove image if parental consent required", function() { - messageView.hideMessage(); - expect($(messageSelector).text().trim()).toBe(''); + var imageView = createImageView({ownProfile: true, hasImage: false, yearOfBirth: ''}); + imageView.render(); + + spyOn(imageView, 'clickedUploadButton'); + spyOn(imageView, 'clickedRemoveButton'); + + expect(imageView.$('.u-field-upload-button').css('display') === 'none').toBeTruthy(); + expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); + + imageView.$('.u-field-upload-button').click(); + imageView.$('.u-field-remove-button').click(); + + expect(imageView.clickedUploadButton).not.toHaveBeenCalled(); + expect(imageView.clickedRemoveButton).not.toHaveBeenCalled(); + }); + + it("can't upload and remove image on others profile", function() { + + var imageView = createImageView({ownProfile: false}); + imageView.render(); + + spyOn(imageView, 'clickedUploadButton'); + spyOn(imageView, 'clickedRemoveButton'); + + expect(imageView.$('.u-field-upload-button').css('display') === 'none').toBeTruthy(); + expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); + + imageView.$('.u-field-upload-button').click(); + imageView.$('.u-field-remove-button').click(); + + expect(imageView.clickedUploadButton).not.toHaveBeenCalled(); + expect(imageView.clickedRemoveButton).not.toHaveBeenCalled(); + }); + + it("shows message if we try to navigate away during image upload/remove", function() { + var imageView = createImageView({ownProfile: true, hasImage: false}); + spyOn(imageView, 'onBeforeUnload'); + imageView.render(); + + initializeUploader(imageView); + + // Add image to upload queue, this will validate image size and send POST request to upload image + imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]}); + + // Verify image upload progress message + verifyImageUploadButtonMessage(imageView, true); + + $(window).trigger('beforeunload'); + expect(imageView.onBeforeUnload).toHaveBeenCalled(); + }); + + it("shows error message for HTTP 500", function() { + var imageView = createImageView({ownProfile: true, hasImage: false}); + imageView.render(); + + var requests = AjaxHelpers.requests(this); + + initializeUploader(imageView); + + // Add image to upload queue. Validate the image size and send POST request to upload image + imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]}); + + // Verify image upload progress message + verifyImageUploadButtonMessage(imageView, true); + + // Verify if POST request received for image upload + AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_UPLOAD_API_URL, new FormData()); + + // Send HTTP 500 + AjaxHelpers.respondWithError(requests); + + expect($('.message-banner').text().trim()).toBe(imageView.errorMessage); + }); }); }); }); diff --git a/lms/static/js/spec/student_profile/learner_profile_view_spec.js b/lms/static/js/spec/student_profile/learner_profile_view_spec.js index fd869a9e06..ddf0ebac87 100644 --- a/lms/static/js/spec/student_profile/learner_profile_view_spec.js +++ b/lms/static/js/spec/student_profile/learner_profile_view_spec.js @@ -124,7 +124,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j }; beforeEach(function () { - setFixtures('

Loading

'); + loadFixtures('js/fixtures/student_profile/student_profile.html'); TemplateHelpers.installTemplate('templates/fields/field_readonly'); TemplateHelpers.installTemplate('templates/fields/field_dropdown'); TemplateHelpers.installTemplate('templates/fields/field_textarea'); diff --git a/lms/static/js/spec/views/message_banner_spec.js b/lms/static/js/spec/views/message_banner_spec.js new file mode 100644 index 0000000000..b5f01cd3ac --- /dev/null +++ b/lms/static/js/spec/views/message_banner_spec.js @@ -0,0 +1,27 @@ +define(['backbone', 'jquery', 'underscore', 'js/views/message_banner' + ], + function (Backbone, $, _, MessageBannerView) { + 'use strict'; + + describe("MessageBannerView", function () { + + beforeEach(function () { + setFixtures('
'); + TemplateHelpers.installTemplate("templates/fields/message_banner"); + }); + + it('renders message correctly', function() { + var messageSelector = '.message-banner'; + var messageView = new MessageBannerView({ + el: $(messageSelector) + }); + + messageView.showMessage('I am message view'); + // Verify error message + expect($(messageSelector).text().trim()).toBe('I am message view'); + + messageView.hideMessage(); + expect($(messageSelector).text().trim()).toBe(''); + }); + }); + }); diff --git a/lms/static/js/student_profile/views/learner_profile_factory.js b/lms/static/js/student_profile/views/learner_profile_factory.js index 8f3a9f26a1..95bacf204e 100644 --- a/lms/static/js/student_profile/views/learner_profile_factory.js +++ b/lms/static/js/student_profile/views/learner_profile_factory.js @@ -9,7 +9,7 @@ 'js/student_profile/views/learner_profile_view', 'js/student_account/views/account_settings_fields', 'js/views/message_banner' - ], function (gettext, $, _, Backbone, AccountSettingsModel, AccountPreferencesModel, FieldsView, + ], function (gettext, $, _, Backbone, Logger, AccountSettingsModel, AccountPreferencesModel, FieldsView, LearnerProfileFieldsView, LearnerProfileView, AccountSettingsFieldViews, MessageBannerView) { return function (options) { diff --git a/lms/static/js/student_profile/views/learner_profile_view.js b/lms/static/js/student_profile/views/learner_profile_view.js index 36f477b6d7..9aa6d0e441 100644 --- a/lms/static/js/student_profile/views/learner_profile_view.js +++ b/lms/static/js/student_profile/views/learner_profile_view.js @@ -48,21 +48,15 @@ this.$('.profile-section-one-fields').append(this.options.usernameFieldView.render().el); var imageView = this.options.profileImageFieldView; - imageView.undelegateEvents(); this.$('.profile-image-field').append(imageView.render().el); - imageView.delegateEvents(); if (this.showFullProfile()) { _.each(this.options.sectionOneFieldViews, function (fieldView) { - fieldView.undelegateEvents(); view.$('.profile-section-one-fields').append(fieldView.render().el); - fieldView.delegateEvents(); }); _.each(this.options.sectionTwoFieldViews, function (fieldView) { - fieldView.undelegateEvents(); view.$('.profile-section-two-fields').append(fieldView.render().el); - fieldView.delegateEvents(); }); } }, diff --git a/lms/static/js/views/fields.js b/lms/static/js/views/fields.js index 932f4fb2d5..f237d01abe 100644 --- a/lms/static/js/views/fields.js +++ b/lms/static/js/views/fields.js @@ -225,6 +225,7 @@ value: this.modelValue(), message: this.helpMessage })); + this.delegateEvents(); return this; }, @@ -260,6 +261,7 @@ value: this.modelValue(), message: this.helpMessage })); + this.delegateEvents(); return this; }, @@ -308,7 +310,7 @@ selectOptions: this.options.options, message: this.helpMessage })); - + this.delegateEvents(); this.updateValueInField(); if (this.editable === 'toggle') { @@ -415,7 +417,7 @@ value: value, message: this.helpMessage })); - + this.delegateEvents(); this.title((this.modelValue() || this.mode === 'edit') ? this.options.title : this.indicators['plus'] + this.options.title); if (this.editable === 'toggle') { @@ -491,6 +493,7 @@ linkHref: this.options.linkHref, message: this.helpMessage })); + this.delegateEvents(); return this; }, @@ -523,7 +526,8 @@ events: { 'click .u-field-upload-button': 'clickedUploadButton', - 'click .u-field-remove-button': 'clickedRemoveButton' + 'click .u-field-remove-button': 'clickedRemoveButton', + 'click .upload-submit': 'clickedUploadButton' }, initialize: function (options) { @@ -543,6 +547,7 @@ removeButtonIcon: _.result(this, 'iconRemove'), removeButtonTitle: _.result(this, 'removeButtonTitle') })); + this.delegateEvents(); this.updateButtonsVisibility(); this.watchForPageUnload(); return this; @@ -558,9 +563,9 @@ uploadButtonTitle: function () { if (this.isShowingPlaceholder()) { - return _.result(this, 'titleAdd') + return _.result(this, 'titleAdd'); } else { - return _.result(this, 'titleEdit') + return _.result(this, 'titleEdit'); } }, @@ -569,7 +574,7 @@ }, isEditingAllowed: function () { - return true + return true; }, isShowingPlaceholder: function () { @@ -594,7 +599,7 @@ } }, - clickedUploadButton: function (e, data) { + clickedUploadButton: function () { $(this.uploadButtonSelector).fileupload({ url: this.options.imageUploadUrl, type: 'POST', @@ -604,24 +609,22 @@ }); }, - clickedRemoveButton: function (e, data) { + clickedRemoveButton: function () { var view = this; this.setCurrentStatus('removing'); this.setUploadButtonVisibility('none'); this.showRemovalInProgressMessage(); - $.ajax({ + $.ajax({ type: 'POST', - url: this.options.imageRemoveUrl, - success: function (data, status, xhr) { - view.imageChangeSucceeded(); - }, - error: function (xhr, status, error) { - view.showImageChangeFailedMessage(xhr.status, xhr.responseText); - } + url: this.options.imageRemoveUrl + }).done(function () { + view.imageChangeSucceeded(); + }).fail(function (jqXHR) { + view.showImageChangeFailedMessage(jqXHR.status, jqXHR.responseText); }); }, - imageChangeSucceeded: function (e, data) { + imageChangeSucceeded: function () { this.render(); }, @@ -645,11 +648,19 @@ var humanReadableSize; if (imageBytes < this.options.imageMinBytes) { humanReadableSize = this.bytesToHumanReadable(this.options.imageMinBytes); - this.showErrorMessage(interpolate_text(gettext("Your image must be at least {size} in size."), {size: humanReadableSize})); + this.showErrorMessage( + interpolate_text( + gettext("Your image must be at least {size} in size."), {size: humanReadableSize} + ) + ); return false; } else if (imageBytes > this.options.imageMaxBytes) { humanReadableSize = this.bytesToHumanReadable(this.options.imageMaxBytes); - this.showErrorMessage(interpolate_text(gettext("Your image must be smaller than {size} in size."), {size: humanReadableSize})); + this.showErrorMessage( + interpolate_text( + gettext("Your image must be smaller than {size} in size."), {size: humanReadableSize} + ) + ); return false; } return true; @@ -675,27 +686,25 @@ return this.$('.image-wrapper').attr('data-status'); }, - inProgress: function() { - var status = this.getCurrentStatus(); - return _.isUndefined(status) ? false : true; - }, - watchForPageUnload: function () { $(window).on('beforeunload', this.onBeforeUnload); }, onBeforeUnload: function () { - console.log('Do you really want to go away?'); var status = this.getCurrentStatus(); if (status === 'uploading') { - return gettext("Upload is in progress. To avoid errors, stay on this page until the process is complete."); + return gettext( + "Upload is in progress. To avoid errors, stay on this page until the process is complete." + ); } else if (status === 'removing') { - return gettext("Removal is in progress. To avoid errors, stay on this page until the process is complete."); + return gettext( + "Removal is in progress. To avoid errors, stay on this page until the process is complete." + ); } }, bytesToHumanReadable: function (size) { - var units = ['Bytes', 'KB', 'MB']; + var units = ['bytes', 'KB', 'MB']; var i = 0; while(size >= 1024) { size /= 1024; diff --git a/lms/static/js/views/message_banner.js b/lms/static/js/views/message_banner.js index 6227f8d7db..87e36fafa0 100644 --- a/lms/static/js/views/message_banner.js +++ b/lms/static/js/views/message_banner.js @@ -6,14 +6,18 @@ var MessageBannerView = Backbone.View.extend({ - initialize: function (options) { + initialize: function () { this.template = _.template($('#message_banner-tpl').text()); }, render: function () { - this.$el.html(this.template({ - message: this.message - })); + if (_.isUndefined(this.message) || _.isNull(this.message)) { + this.$el.html(''); + } else { + this.$el.html(this.template({ + message: this.message + })); + } return this; }, @@ -23,10 +27,11 @@ }, hideMessage: function () { - this.$el.html(''); + this.message = null; + this.render(); } }); return MessageBannerView; - }) + }); }).call(this, define || RequireJS.define); diff --git a/lms/static/sass/views/_learner-profile.scss b/lms/static/sass/views/_learner-profile.scss index 2679d6af98..d60693d199 100644 --- a/lms/static/sass/views/_learner-profile.scss +++ b/lms/static/sass/views/_learner-profile.scss @@ -30,7 +30,6 @@ background: transparent !important; border: none !important; padding: 0; - -webkit-tap-highlight-color: transparent; } .u-field-image { @@ -43,16 +42,18 @@ .image-frame { position: relative; - width: 120px; - height: 120px; + width: $profile-image-dimension; + height: $profile-image-dimension; + border-radius: ($baseline/4); } .u-field-upload-button { - width: 120px; - height: 120px; + width: $profile-image-dimension; + height: $profile-image-dimension; position: absolute; top: 0; opacity: 0; + @include transition(all $tmg-f1 ease-in-out 0s); i { color: $white; @@ -61,13 +62,15 @@ .upload-button-icon, .upload-button-title { text-align: center; - transform: translateY(45px); + transform: translateY(35px); display: block; color: $white; + margin-bottom: ($baseline/4); + line-height: 1.3em; } .upload-button-input { - width: 120px; + width: $profile-image-dimension; height: 100%; position: absolute; top: 0; @@ -77,17 +80,24 @@ } .u-field-remove-button { - width: 120px; - height: 20px; + width: $profile-image-dimension; + height: $baseline; opacity: 0; position: relative; margin-top: 2px; text-align: center; + + &:focus, &:active { + box-shadow: none; + outline: 0; + } } &:hover { .u-field-upload-button, .u-field-remove-button { opacity: 1; + background-color: $shadow-d2; + border-radius: ($baseline/4); } } } diff --git a/lms/templates/fields/field_image.underscore b/lms/templates/fields/field_image.underscore index 5c345ada5e..496c237f6d 100644 --- a/lms/templates/fields/field_image.underscore +++ b/lms/templates/fields/field_image.underscore @@ -6,10 +6,11 @@ <%= uploadButtonTitle %> + - \ No newline at end of file +