Commit adf111e4 by Usman Khalid Committed by Andy Armstrong

Adding functionlity to upload/remove profile image.

TNL-1538
parent 938dcce0
......@@ -70,6 +70,10 @@ def learner_profile_context(logged_in_username, profile_username, user_is_staff)
'default_public_account_fields': settings.ACCOUNT_VISIBILITY_CONFIGURATION['public_fields'],
'accounts_api_url': reverse("accounts_api", kwargs={'username': profile_username}),
'preferences_api_url': reverse('preferences_api', kwargs={'username': profile_username}),
'profile_image_upload_url': reverse('profile_image_upload', kwargs={'username': profile_username}),
'profile_image_remove_url': reverse('profile_image_remove', kwargs={'username': profile_username}),
'profile_image_max_bytes': settings.PROFILE_IMAGE_MAX_BYTES,
'profile_image_min_bytes': settings.PROFILE_IMAGE_MIN_BYTES,
'account_settings_page_url': reverse('account_settings'),
'has_preferences_access': (logged_in_username == profile_username or user_is_staff),
'own_profile': (logged_in_username == profile_username),
......
......@@ -3,6 +3,8 @@ define(['underscore'], function(_) {
var USER_ACCOUNTS_API_URL = '/api/user/v0/accounts/student';
var USER_PREFERENCES_API_URL = '/api/user/v0/preferences/student';
var IMAGE_UPLOAD_API_URL = '/api/profile_images/v0/staff/upload';
var IMAGE_REMOVE_API_URL = '/api/profile_images/v0/staff/remove';
var USER_ACCOUNTS_DATA = {
username: 'student',
......@@ -27,9 +29,12 @@ define(['underscore'], function(_) {
['0', 'Option 0'],
['1', 'Option 1'],
['2', 'Option 2'],
['3', 'Option 3'],
['3', 'Option 3']
];
var IMAGE_MAX_BYTES = 1024 * 1024;
var IMAGE_MIN_BYTES = 100;
var expectLoadingIndicatorIsVisible = function (view, visible) {
if (visible) {
expect($('.ui-loading-indicator')).not.toHaveClass('is-hidden');
......@@ -92,6 +97,10 @@ define(['underscore'], function(_) {
return {
USER_ACCOUNTS_API_URL: USER_ACCOUNTS_API_URL,
USER_PREFERENCES_API_URL: USER_PREFERENCES_API_URL,
IMAGE_UPLOAD_API_URL: IMAGE_UPLOAD_API_URL,
IMAGE_REMOVE_API_URL: IMAGE_REMOVE_API_URL,
IMAGE_MAX_BYTES: IMAGE_MAX_BYTES,
IMAGE_MIN_BYTES: IMAGE_MIN_BYTES,
USER_ACCOUNTS_DATA: USER_ACCOUNTS_DATA,
USER_PREFERENCES_DATA: USER_PREFERENCES_DATA,
FIELD_OPTIONS: FIELD_OPTIONS,
......
......@@ -6,11 +6,12 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j
'js/student_account/models/user_preferences_model',
'js/student_profile/views/learner_profile_fields',
'js/student_profile/views/learner_profile_view',
'js/student_account/views/account_settings_fields'
'js/student_account/views/account_settings_fields',
'js/views/message_banner'
],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews,
UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView,
AccountSettingsFieldViews) {
AccountSettingsFieldViews, MessageBannerView) {
'use strict';
describe("edx.user.LearnerProfileView", function () {
......@@ -46,6 +47,21 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j
accountSettingsPageUrl: '/account/settings/'
});
var messageView = new MessageBannerView({
el: $('.message-banner')
});
var profileImageFieldView = new FieldsView.ImageFieldView({
model: accountSettingsModel,
valueAttribute: "profile_image",
editable: editable,
messageView: messageView,
imageMaxBytes: Helpers.IMAGE_MAX_BYTES,
imageMinBytes: Helpers.IMAGE_MIN_BYTES,
imageUploadUrl: Helpers.IMAGE_UPLOAD_API_URL,
imageRemoveUrl: Helpers.IMAGE_REMOVE_API_URL
});
var usernameFieldView = new FieldViews.ReadonlyFieldView({
model: accountSettingsModel,
valueAttribute: "username",
......@@ -107,10 +123,12 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j
};
beforeEach(function () {
setFixtures('<div class="wrapper-profile"><div class="ui-loading-indicator"><p><span class="spin"><i class="icon fa fa-refresh"></i></span> <span class="copy">Loading</span></p></div><div class="ui-loading-error is-hidden"><i class="fa fa-exclamation-triangle message-error" aria-hidden=true></i><span class="copy">An error occurred. Please reload the page.</span></div></div>');
setFixtures('<div class="message-banner"></div><div class="wrapper-profile"><div class="ui-loading-indicator"><p><span class="spin"><i class="icon fa fa-refresh"></i></span> <span class="copy">Loading</span></p></div><div class="ui-loading-error is-hidden"><i class="fa fa-exclamation-triangle message-error" aria-hidden=true></i><span class="copy">An error occurred. Please reload the page.</span></div></div>');
TemplateHelpers.installTemplate('templates/fields/field_readonly');
TemplateHelpers.installTemplate('templates/fields/field_dropdown');
TemplateHelpers.installTemplate('templates/fields/field_textarea');
TemplateHelpers.installTemplate('templates/fields/field_image');
TemplateHelpers.installTemplate('templates/message_banner');
TemplateHelpers.installTemplate('templates/student_profile/learner_profile');
});
......
......@@ -22,6 +22,7 @@
bio: null,
language_proficiencies: [],
requires_parental_consent: true,
profile_image: null,
default_public_account_fields: []
},
......@@ -39,6 +40,15 @@
return response;
},
hasProfileImage: function () {
var profile_image = this.get('profile_image');
return (_.isObject(profile_image) && profile_image['has_image'] === true);
},
profileImageUrl: function () {
return this.get('profile_image')['image_url_large'];
},
isAboveMinimumAge: function() {
var isBirthDefined = !(_.isUndefined(this.get('year_of_birth')) || _.isNull(this.get('year_of_birth')));
return isBirthDefined && !(this.get("requires_parental_consent"));
......
......@@ -7,9 +7,10 @@
'js/views/fields',
'js/student_profile/views/learner_profile_fields',
'js/student_profile/views/learner_profile_view',
'js/student_account/views/account_settings_fields'
], function (gettext, $, _, Backbone, Logger, AccountSettingsModel, AccountPreferencesModel, FieldsView,
LearnerProfileFieldsView, LearnerProfileView, AccountSettingsFieldViews) {
'js/student_account/views/account_settings_fields',
'js/views/message_banner'
], function (gettext, $, _, Backbone, AccountSettingsModel, AccountPreferencesModel, FieldsView,
LearnerProfileFieldsView, LearnerProfileView, AccountSettingsFieldViews, MessageBannerView) {
return function (options) {
......@@ -25,6 +26,10 @@
var editable = options.own_profile ? 'toggle' : 'never';
var messageView = new MessageBannerView({
el: $('.message-banner')
});
var accountPrivacyFieldView = new LearnerProfileFieldsView.AccountPrivacyFieldView({
model: accountPreferencesModel,
required: true,
......@@ -40,6 +45,17 @@
accountSettingsPageUrl: options.account_settings_page_url
});
var profileImageFieldView = new LearnerProfileFieldsView.ProfileImageFieldView({
model: accountSettingsModel,
valueAttribute: "profile_image",
editable: editable === 'toggle',
messageView: messageView,
imageMaxBytes: options['profile_image_max_bytes'],
imageMinBytes: options['profile_image_min_bytes'],
imageUploadUrl: options['profile_image_upload_url'],
imageRemoveUrl: options['profile_image_remove_url']
});
var usernameFieldView = new FieldsView.ReadonlyFieldView({
model: accountSettingsModel,
valueAttribute: "username",
......@@ -47,7 +63,6 @@
});
var sectionOneFieldViews = [
usernameFieldView,
new FieldsView.DropdownFieldView({
model: accountSettingsModel,
required: true,
......@@ -94,6 +109,7 @@
accountSettingsModel: accountSettingsModel,
preferencesModel: accountPreferencesModel,
accountPrivacyFieldView: accountPrivacyFieldView,
profileImageFieldView: profileImageFieldView,
usernameFieldView: usernameFieldView,
sectionOneFieldViews: sectionOneFieldViews,
sectionTwoFieldViews: sectionTwoFieldViews
......
......@@ -48,6 +48,64 @@
}
});
LearnerProfileFieldViews.ProfileImageFieldView = FieldViews.ImageFieldView.extend({
imageUrl: function () {
return this.model.profileImageUrl();
},
imageAltText: function () {
return interpolate_text(gettext("Profile image for {username}"), {username: this.model.get('username')});
},
imageChangeSucceeded: function (e, data) {
var view = this;
// Update model to get the latest urls of profile image.
this.model.fetch().done(function () {
view.setCurrentStatus('');
}).fail(function () {
view.showErrorMessage(view.errorMessage);
});
},
imageChangeFailed: function (e, data) {
this.setCurrentStatus('');
if (_.contains([400, 404], data.jqXHR.status)) {
try {
var errors = JSON.parse(data.jqXHR.responseText);
this.showErrorMessage(errors.user_message);
} catch (error) {
this.showErrorMessage(this.errorMessage);
}
} else {
this.showErrorMessage(this.errorMessage);
}
this.render();
},
showErrorMessage: function (message) {
this.options.messageView.showMessage(message);
},
isEditingAllowed: function () {
return this.model.isAboveMinimumAge();
},
isShowingPlaceholder: function () {
return !this.model.hasProfileImage();
},
clickedRemoveButton: function (e, data) {
this.options.messageView.hideMessage();
this._super(e, data);
},
fileSelected: function (e, data) {
this.options.messageView.hideMessage();
this._super(e, data);
}
});
return LearnerProfileFieldViews;
});
}).call(this, define || RequireJS.define);
......@@ -24,7 +24,6 @@
render: function () {
this.$el.html(this.template({
username: this.options.accountSettingsModel.get('username'),
profilePhoto: 'http://www.teachthought.com/wp-content/uploads/2012/07/edX-120x120.jpg',
ownProfile: this.options.ownProfile,
showFullProfile: this.showFullProfile()
}));
......@@ -48,6 +47,11 @@
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();
......
......@@ -499,6 +499,207 @@
}
});
FieldViews.ImageFieldView = FieldViews.FieldView.extend({
fieldType: 'image',
templateSelector: '#field_image-tpl',
uploadButtonSelector: '.upload-button-input',
titleAdd: gettext("Upload an image"),
titleEdit: gettext("Change image"),
titleRemove: gettext("Remove"),
titleUploading: gettext("Uploading"),
titleRemoving: gettext("Removing"),
titleImageAlt: '',
iconUpload: '<i class="icon fa fa-camera" aria-hidden="true"></i>',
iconRemove: '<i class="icon fa fa-remove" aria-hidden="true"></i>',
iconProgress: '<i class="icon fa fa-spinner fa-pulse fa-spin" aria-hidden="true"></i>',
errorMessage: gettext("An error has occurred. Refresh the page, and then try again."),
events: {
'click .u-field-upload-button': 'clickedUploadButton',
'click .u-field-remove-button': 'clickedRemoveButton'
},
initialize: function (options) {
this._super(options);
_.bindAll(this, 'render', 'imageChangeSucceeded', 'imageChangeFailed', 'fileSelected',
'watchForPageUnload', 'onBeforeUnload');
this.listenTo(this.model, "change:" + this.options.valueAttribute, this.render);
},
render: function () {
this.$el.html(this.template({
id: this.options.valueAttribute,
imageUrl: _.result(this, 'imageUrl'),
imageAltText: _.result(this, 'imageAltText'),
uploadButtonIcon: _.result(this, 'iconUpload'),
uploadButtonTitle: _.result(this, 'uploadButtonTitle'),
removeButtonIcon: _.result(this, 'iconRemove'),
removeButtonTitle: _.result(this, 'removeButtonTitle')
}));
this.updateButtonsVisibility();
this.watchForPageUnload();
return this;
},
showErrorMessage: function () {
},
imageUrl: function () {
return '';
},
uploadButtonTitle: function () {
if (this.isShowingPlaceholder()) {
return _.result(this, 'titleAdd')
} else {
return _.result(this, 'titleEdit')
}
},
removeButtonTitle: function () {
return this.titleRemove;
},
isEditingAllowed: function () {
return true
},
isShowingPlaceholder: function () {
return false;
},
setUploadButtonVisibility: function (state) {
this.$('.u-field-upload-button').css('display', state);
},
setRemoveButtonVisibility: function (state) {
this.$('.u-field-remove-button').css('display', state);
},
updateButtonsVisibility: function () {
if (!this.isEditingAllowed() || !this.options.editable) {
this.setUploadButtonVisibility('none');
}
if (this.isShowingPlaceholder() || !this.options.editable) {
this.setRemoveButtonVisibility('none');
}
},
clickedUploadButton: function () {
$(this.uploadButtonSelector).fileupload({
url: this.options.imageUploadUrl,
type: 'POST',
add: this.fileSelected,
done: this.imageChangeSucceeded,
fail: this.imageChangeFailed
});
},
clickedRemoveButton: function () {
var view = this;
this.setCurrentStatus('removing');
this.setUploadButtonVisibility('none');
this.showRemovalInProgressMessage();
$.ajax({
type: 'POST',
url: this.options.imageRemoveUrl,
success: function (data, status, xhr) {
view.imageChangeSucceeded();
},
error: function (xhr, status, error) {
view.imageChangeFailed();
}
});
},
imageChangeSucceeded: function (e, data) {
this.render();
},
imageChangeFailed: function (e, data) {
},
fileSelected: function (e, data) {
if (this.validateImageSize(data.files[0].size)) {
data.formData = {file: data.files[0]};
this.setCurrentStatus('uploading');
this.setRemoveButtonVisibility('none');
this.showUploadInProgressMessage();
data.submit();
}
},
validateImageSize: function (imageBytes) {
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}));
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}));
return false;
}
return true;
},
showUploadInProgressMessage: function () {
this.$('.u-field-upload-button').css('opacity', 1);
this.$('.upload-button-icon').html(this.iconProgress);
this.$('.upload-button-title').html(this.titleUploading);
},
showRemovalInProgressMessage: function () {
this.$('.u-field-remove-button').css('opacity', 1);
this.$('.remove-button-icon').html(this.iconProgress);
this.$('.remove-button-title').html(this.titleRemoving);
},
setCurrentStatus: function (status) {
this.$('.image-wrapper').attr('data-status', status);
},
getCurrentStatus: function () {
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 () {
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.");
} else if (status === 'removing') {
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 i = 0;
while(size >= 1024) {
size /= 1024;
++i;
}
return size.toFixed(1)*1 + ' ' + units[i];
}
});
return FieldViews;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'gettext', 'jquery', 'underscore', 'backbone'
], function (gettext, $, _, Backbone) {
var MessageBannerView = Backbone.View.extend({
initialize: function (options) {
this.template = _.template($('#message_banner-tpl').text());
},
render: function () {
this.$el.html(this.template({
message: this.message
}));
return this;
},
showMessage: function (message) {
this.message = message;
this.render();
},
hideMessage: function () {
this.$el.html('');
}
});
return MessageBannerView;
})
}).call(this, define || RequireJS.define);
......@@ -7,7 +7,7 @@
// * +Settings Section
.view-profile {
$profile-photo-dimension: 120px;
$profile-image-dimension: 120px;
.content-wrapper {
background-color: $white;
......@@ -23,6 +23,75 @@
width: ($baseline*5);
}
.profile-image-field {
@include float(left);
button {
background: transparent !important;
border: none !important;
padding: 0;
}
.u-field-image {
padding-top: 0;
}
.image-wrapper {
width: $profile-image-dimension;
position: relative;
.image-frame {
position: relative;
width: 120px;
height: 120px;
}
.u-field-upload-button {
width: 120px;
height: 120px;
position: absolute;
top: 0;
opacity: 0;
i {
color: $white;
}
}
.upload-button-icon, .upload-button-title {
text-align: center;
transform: translateY(45px);
display: block;
color: $white;
}
.upload-button-input {
width: 120px;
height: 100%;
position: absolute;
top: 0;
left: 0;
opacity: 0;
cursor: pointer;
}
.u-field-remove-button {
width: 120px;
height: 20px;
opacity: 0;
position: relative;
margin-top: 2px;
text-align: center;
}
&:hover {
.u-field-upload-button, .u-field-remove-button {
opacity: 1;
}
}
}
}
.wrapper-profile {
min-height: 200px;
......@@ -77,14 +146,6 @@
width: 100%;
display: inline-block;
margin-top: ($baseline*1.5);
.profile-photo {
@include float(left);
height: $profile-photo-dimension;
width: $profile-photo-dimension;
display: inline-block;
vertical-align: top;
}
}
.profile-section-one-fields {
......
<div class="image-wrapper">
<img class="image-frame" src="<%- gettext(imageUrl) %>" alt="<%=imageAltText%>"/>
<div class="u-field-actions">
<label class="u-field-upload-button">
<span class="upload-button-icon" aria-hidden="true"><%= uploadButtonIcon %></span>
<span class="upload-button-title" aria-live="polite"><%= uploadButtonTitle %></span>
<input class="upload-button-input" type="file" name="<%= id %>"/>
</label>
<button class="u-field-remove-button" type="button">
<span class="remove-button-icon" aria-hidden="true"><%= removeButtonIcon %></span>
<span class="remove-button-title" aria-live="polite"><%= removeButtonTitle %></span>
</button>
</div>
</div>
\ No newline at end of file
<div class="wrapper-msg urgency-high">
<div class="msg">
<div class="msg-content">
<div class="copy">
<p><%-message%></p>
</div>
</div>
</div>
</div>
\ No newline at end of file
......@@ -10,27 +10,36 @@
<%block name="bodyclass">view-profile</%block>
<%block name="header_extras">
% for template_name in ["field_dropdown", "field_textarea", "field_readonly"]:
% for template_name in ["field_dropdown", "field_image", "field_textarea", "field_readonly"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="fields/${template_name}.underscore" />
</script>
% endfor
% for template_name in ["learner_profile",]:
% for template_name in ["learner_profile"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="student_profile/${template_name}.underscore" />
</script>
% endfor
% for template_name in ["message_banner"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="${template_name}.underscore" />
</script>
% endfor
</%block>
<div class="message-banner" aria-live="polite"></div>
<div class="wrapper-profile">
<div class="ui-loading-indicator">
<p><span class="spin"><i class="icon fa fa-refresh"></i></span> <span class="copy">${_("Loading")}</span></p>
<p><span class="spin"><i class="icon fa fa-refresh" aria-hidden="true"></i></span> <span class="copy">${_("Loading")}</span></p>
</div>
</div>
<%block name="headextra">
<%static:css group='style-course'/>
<script type="text/javascript" src="${static.url('js/vendor/jQuery-File-Upload/js/jquery.fileupload.js')}"></script>
<script>
(function (require) {
require(['js/student_profile/views/learner_profile_factory'], function(setupLearnerProfile) {
......
......@@ -3,8 +3,7 @@
<div class="wrapper-profile-sections account-settings-container">
<div class="wrapper-profile-section-one">
<div class="profile-photo">
<img src="<%- profilePhoto %>" alt="Profile image for <%- username %>">
<div class="profile-image-field">
</div>
<div class="profile-section-one-fields">
......
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