diff --git a/common/djangoapps/student/migrations/0012_sociallink.py b/common/djangoapps/student/migrations/0012_sociallink.py new file mode 100644 index 0000000000..9e98824b63 --- /dev/null +++ b/common/djangoapps/student/migrations/0012_sociallink.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('student', '0011_course_key_field_to_foreign_key'), + ] + + operations = [ + migrations.CreateModel( + name='SocialLink', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('platform', models.CharField(max_length=30)), + ('social_link', models.CharField(max_length=100, blank=True)), + ('user_profile', models.ForeignKey(related_name='social_links', to='student.UserProfile')), + ], + ), + ] diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 580b00bd70..74a9748260 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -2374,6 +2374,21 @@ class LanguageProficiency(models.Model): ) +class SocialLink(models.Model): # pylint: disable=model-missing-unicode + """ + Represents a URL connecting a particular social platform to a user's social profile. + + The platforms are listed in the lms/common.py file under SOCIAL_PLATFORMS. + Each entry has a display name, a url_stub that describes a required + component of the stored URL and an example of a valid URL. + + The stored social_link value must adhere to the form 'https://www.[url_stub][username]'. + """ + user_profile = models.ForeignKey(UserProfile, db_index=True, related_name='social_links') + platform = models.CharField(max_length=30) + social_link = models.CharField(max_length=100, blank=True) + + class CourseEnrollmentAttribute(models.Model): """ Provide additional information about the user's enrollment. diff --git a/common/test/acceptance/tests/lms/test_account_settings.py b/common/test/acceptance/tests/lms/test_account_settings.py index 9a0eeda4b6..3a79f57955 100644 --- a/common/test/acceptance/tests/lms/test_account_settings.py +++ b/common/test/acceptance/tests/lms/test_account_settings.py @@ -124,6 +124,7 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, AcceptanceTest): """ super(AccountSettingsPageTest, self).setUp() self.full_name = XSS_INJECTION + self.social_link = '' self.username, self.user_id = self.log_in_as_unique_user(full_name=self.full_name) self.visit_account_settings_page() @@ -177,6 +178,14 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, AcceptanceTest): 'Year of Birth', 'Preferred Language', ] + }, + { + 'title': 'Social Media Links', + 'fields': [ + 'Twitter Link', + 'Facebook Link', + 'LinkedIn Link', + ] } ] @@ -463,6 +472,18 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, AcceptanceTest): actual_events ) + def test_social_links_field(self): + """ + Test behaviour of one of the social media links field. + """ + self._test_text_field( + u'social_links', + u'Twitter Link', + self.social_link, + u'www.google.com/invalidlink', + [u'https://www.twitter.com/edX', self.social_link], + ) + def test_linked_accounts(self): """ Test that fields for third party auth providers exist. diff --git a/lms/envs/common.py b/lms/envs/common.py index 9a63804cb6..b20d06c969 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2931,6 +2931,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { 'date_joined', 'language_proficiencies', 'bio', + 'social_links', 'account_privacy', # Not an actual field, but used to signal whether badges should be public. 'accomplishments_shared', @@ -2953,6 +2954,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { "date_joined", "profile_image", "language_proficiencies", + "social_links", "name", "gender", "goals", @@ -2965,6 +2967,32 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { ] } +# The current list of social platforms to be shown to the user. +# +# url_stub represents the host URL, it must end with a forward +# slash and represent the profile at https://www.[url_stub][username] +# +# The example will be used as a placeholder in the social link +# input field as well as in some messaging describing an example of a +# valid link. +SOCIAL_PLATFORMS = { + 'facebook': { + 'display_name': 'Facebook', + 'url_stub': 'facebook.com/', + 'example': 'https://www.facebook.com/username' + }, + 'twitter': { + 'display_name': 'Twitter', + 'url_stub': 'twitter.com/', + 'example': 'https://www.twitter.com/username' + }, + 'linkedin': { + 'display_name': 'LinkedIn', + 'url_stub': 'linkedin.com/in/', + 'example': 'www.linkedin.com/in/username' + } +} + # E-Commerce API Configuration ECOMMERCE_PUBLIC_URL_ROOT = None ECOMMERCE_API_URL = None diff --git a/lms/static/js/spec/student_account/account_settings_fields_spec.js b/lms/static/js/spec/student_account/account_settings_fields_spec.js index 165e80a66c..d9b1110b28 100644 --- a/lms/static/js/spec/student_account/account_settings_fields_spec.js +++ b/lms/static/js/spec/student_account/account_settings_fields_spec.js @@ -1,21 +1,23 @@ define(['backbone', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/views/fields', - 'js/spec/views/fields_helpers', - 'js/spec/student_account/account_settings_fields_helpers', - 'js/student_account/views/account_settings_fields', - 'js/student_account/models/user_account_model', - 'string_utils'], - function(Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViews, FieldViewsSpecHelpers, + 'jquery', + 'underscore', + 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', + 'common/js/spec_helpers/template_helpers', + 'js/student_account/models/user_account_model', + 'js/views/fields', + 'js/spec/views/fields_helpers', + 'js/spec/student_account/account_settings_fields_helpers', + 'js/student_account/views/account_settings_fields', + 'js/student_account/models/user_account_model', + 'string_utils'], + function(Backbone, $, _, AjaxHelpers, TemplateHelpers, UserAccountModel, FieldViews, FieldViewsSpecHelpers, AccountSettingsFieldViewSpecHelpers, AccountSettingsFieldViews) { 'use strict'; describe('edx.AccountSettingsFieldViews', function() { var requests, - timerCallback; + timerCallback, // eslint-disable-line no-unused-vars + data; beforeEach(function() { timerCallback = jasmine.createSpy('timerCallback'); @@ -40,7 +42,7 @@ define(['backbone', view.$('.u-field-value > button').click(); expect(view.$('.u-field-value > button').is(':disabled')).toBe(true); AjaxHelpers.expectRequest(requests, 'POST', '/password_reset', 'email=legolas%40woodland.middlearth'); - AjaxHelpers.respondWithJson(requests, {'success': 'true'}); + AjaxHelpers.respondWithJson(requests, {success: 'true'}); FieldViewsSpecHelpers.expectMessageContains( view, "We've sent a message to legolas@woodland.middlearth. " + @@ -130,7 +132,7 @@ define(['backbone', var view = new AccountSettingsFieldViews.LanguagePreferenceFieldView(fieldData).render(); - var data = {'language': FieldViewsSpecHelpers.SELECT_OPTIONS[2][0]}; + data = {language: FieldViewsSpecHelpers.SELECT_OPTIONS[2][0]}; view.$(selector).val(data[fieldData.valueAttribute]).change(); view.$(selector).focusout(); FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); @@ -145,7 +147,7 @@ define(['backbone', AjaxHelpers.respondWithNoContent(requests); FieldViewsSpecHelpers.expectMessageContains(view, 'Your changes have been saved.'); - data = {'language': FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]}; + data = {language: FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]}; view.$(selector).val(data[fieldData.valueAttribute]).change(); view.$(selector).focusout(); FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); @@ -173,13 +175,13 @@ define(['backbone', options: FieldViewsSpecHelpers.SELECT_OPTIONS, persistChanges: true }); - fieldData.model.set({'language_proficiencies': [{'code': FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]}]}); + fieldData.model.set({language_proficiencies: [{code: FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]}]}); var view = new AccountSettingsFieldViews.LanguageProficienciesFieldView(fieldData).render(); expect(view.modelValue()).toBe(FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]); - var data = {'language_proficiencies': [{'code': FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]}]}; + data = {language_proficiencies: [{code: FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]}]}; view.$(selector).val(FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]).change(); view.$(selector).focusout(); FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); diff --git a/lms/static/js/spec/student_account/account_settings_view_spec.js b/lms/static/js/spec/student_account/account_settings_view_spec.js index f171762b53..d97eebc341 100644 --- a/lms/static/js/spec/student_account/account_settings_view_spec.js +++ b/lms/static/js/spec/student_account/account_settings_view_spec.js @@ -1,13 +1,13 @@ define(['backbone', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/spec/student_account/helpers', - 'js/views/fields', - 'js/student_account/models/user_account_model', - 'js/student_account/views/account_settings_view' - ], + 'jquery', + 'underscore', + 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', + 'common/js/spec_helpers/template_helpers', + 'js/spec/student_account/helpers', + 'js/views/fields', + 'js/student_account/models/user_account_model', + 'js/student_account/views/account_settings_view' +], function(Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, FieldViews, UserAccountModel, AccountSettingsView) { 'use strict'; diff --git a/lms/static/js/spec/student_account/helpers.js b/lms/static/js/spec/student_account/helpers.js index 23df61438f..a03ac6aaa9 100644 --- a/lms/static/js/spec/student_account/helpers.js +++ b/lms/static/js/spec/student_account/helpers.js @@ -65,6 +65,23 @@ define(['underscore'], function(_) { }; var IMAGE_MAX_BYTES = 1024 * 1024; var IMAGE_MIN_BYTES = 100; + var SOCIAL_PLATFORMS = { + facebook: { + display_name: 'Facebook', + url_stub: 'facebook.com/', + example: 'https://www.facebook.com/username' + }, + twitter: { + display_name: 'Twitter', + url_stub: 'twitter.com/', + example: 'https://www.twitter.com/username' + }, + linkedin: { + display_name: 'LinkedIn', + url_stub: 'linkedin.com/in/', + example: 'https://www.linkedin.com/in/username' + } + }; var DEFAULT_ACCOUNT_SETTINGS_DATA = { username: 'student', name: 'Student', @@ -77,6 +94,7 @@ define(['underscore'], function(_) { language: 'en-US', date_joined: 'December 17, 1995 03:24:00', bio: 'About the student', + social_links: [{platform: 'facebook', social_link: 'https://www.facebook.com/edX'}], language_proficiencies: [{code: '1'}], profile_image: PROFILE_IMAGE, accomplishments_shared: false @@ -170,6 +188,7 @@ define(['underscore'], function(_) { AUTH_DATA: AUTH_DATA, IMAGE_MAX_BYTES: IMAGE_MAX_BYTES, IMAGE_MIN_BYTES: IMAGE_MIN_BYTES, + SOCIAL_PLATFORMS: SOCIAL_PLATFORMS, createAccountSettingsData: createAccountSettingsData, createUserPreferencesData: createUserPreferencesData, expectLoadingIndicatorIsVisible: expectLoadingIndicatorIsVisible, diff --git a/lms/static/js/student_account/models/user_account_model.js b/lms/static/js/student_account/models/user_account_model.js index 4f9293b92f..da40216eda 100644 --- a/lms/static/js/student_account/models/user_account_model.js +++ b/lms/static/js/student_account/models/user_account_model.js @@ -19,6 +19,7 @@ mailing_address: '', year_of_birth: null, bio: null, + social_links: [], language_proficiencies: [], requires_parental_consent: true, profile_image: null, diff --git a/lms/static/js/student_account/views/account_settings_factory.js b/lms/static/js/student_account/views/account_settings_factory.js index b88a1223da..30e3440bc9 100644 --- a/lms/static/js/student_account/views/account_settings_factory.js +++ b/lms/static/js/student_account/views/account_settings_factory.js @@ -19,14 +19,15 @@ accountUserId, platformName, contactEmail, - allowEmailChange + allowEmailChange, + socialPlatforms ) { - var accountSettingsElement, userAccountModel, userPreferencesModel, aboutSectionsData, + var $accountSettingsElement, userAccountModel, userPreferencesModel, aboutSectionsData, accountsSectionData, ordersSectionData, accountSettingsView, showAccountSettingsPage, showLoadingError, orderNumber, getUserField, userFields, timeZoneDropdownField, countryDropdownField, - emailFieldView; + emailFieldView, socialFields, platformData; - accountSettingsElement = $('.wrapper-account-settings'); + $accountSettingsElement = $('.wrapper-account-settings'); userAccountModel = new UserAccountModel(); userAccountModel.url = userAccountsApiUrl; @@ -64,7 +65,7 @@ aboutSectionsData = [ { title: gettext('Basic Account Information'), - subtitle: gettext('These settings include basic information about your account. You can also specify additional information and see your linked social accounts on this page.'), // eslint-disable-line max-len + subtitle: gettext('These settings include basic information about your account.'), fields: [ { view: new AccountSettingsFieldViews.ReadonlyFieldView({ @@ -191,6 +192,34 @@ } ]; + // Add the social link fields + socialFields = { + title: gettext('Social Media Links'), + subtitle: gettext('Optionally, link your personal accounts to the social media icons on your edX profile.'), // eslint-disable-line max-len + fields: [] + }; + + for (var socialPlatform in socialPlatforms) { // eslint-disable-line guard-for-in, no-restricted-syntax, vars-on-top, max-len + platformData = socialPlatforms[socialPlatform]; + socialFields.fields.push( + { + view: new AccountSettingsFieldViews.SocialLinkTextFieldView({ + model: userAccountModel, + title: gettext(platformData.display_name + ' Link'), + valueAttribute: 'social_links', + helpMessage: gettext( + 'Enter your ' + platformData.display_name + ' username or the URL to your ' + + platformData.display_name + ' page. Delete the URL to remove the link.' + ), + platform: socialPlatform, + persistChanges: true, + placeholder: platformData.example + }) + } + ); + } + aboutSectionsData.push(socialFields); + // set TimeZoneField to listen to CountryField getUserField = function(list, search) { return _.find(list, function(field) { @@ -266,7 +295,7 @@ accountSettingsView = new AccountSettingsView({ model: userAccountModel, accountUserId: accountUserId, - el: accountSettingsElement, + el: $accountSettingsElement, tabSections: { aboutTabSections: aboutSectionsData, accountsTabSections: accountsSectionData, diff --git a/lms/static/js/student_account/views/account_settings_fields.js b/lms/static/js/student_account/views/account_settings_fields.js index 88d229a747..77123c66fd 100644 --- a/lms/static/js/student_account/views/account_settings_fields.js +++ b/lms/static/js/student_account/views/account_settings_fields.js @@ -224,6 +224,39 @@ } } }), + SocialLinkTextFieldView: FieldViews.TextFieldView.extend({ + render: function() { + HtmlUtils.setHtml(this.$el, HtmlUtils.template(field_text_account_template)({ + id: this.options.valueAttribute + '_' + this.options.platform, + title: this.options.title, + value: this.modelValue(), + message: this.options.helpMessage, + placeholder: this.options.placeholder || '' + })); + this.delegateEvents(); + return this; + }, + + modelValue: function() { + var socialLinks = this.model.get(this.options.valueAttribute); + for (var i = 0; i < socialLinks.length; i++) { // eslint-disable-line vars-on-top + if (socialLinks[i].platform === this.options.platform) { + return socialLinks[i].social_link; + } + } + return null; + }, + saveValue: function() { + var attributes, value; + if (this.persistChanges === true) { + attributes = {}; + value = this.fieldValue() != null ? [{platform: this.options.platform, + social_link: this.fieldValue()}] : []; + attributes[this.options.valueAttribute] = value; + this.saveAttributes(attributes); + } + } + }), AuthFieldView: FieldViews.LinkFieldView.extend({ fieldTemplate: field_social_link_template, className: function() { diff --git a/lms/static/js/views/fields.js b/lms/static/js/views/fields.js index 06215c8280..9376d887d2 100644 --- a/lms/static/js/views/fields.js +++ b/lms/static/js/views/fields.js @@ -367,7 +367,8 @@ id: this.options.valueAttribute, title: this.options.title, value: this.modelValue(), - message: this.helpMessage + message: this.helpMessage, + placeholder: this.options.placeholder || '' })); this.delegateEvents(); return this; diff --git a/lms/static/sass/features/_learner-profile.scss b/lms/static/sass/features/_learner-profile.scss index c5ab800d33..33a1305462 100644 --- a/lms/static/sass/features/_learner-profile.scss +++ b/lms/static/sass/features/_learner-profile.scss @@ -195,6 +195,10 @@ .profile-header { @include padding(0, $baseline*2, $baseline, $baseline*3); + @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap + @include padding(0, $baseline*2, $baseline, $baseline*0.75); + } + .header { @extend %t-title4; @extend %t-ultrastrong; @@ -221,11 +225,34 @@ } .profile-section-one-fields { - margin: 0 $baseline/2; + @include margin(0, $baseline/2, 0, $baseline*0.75); + + .social-links { + font-size: 2rem; + padding-top: $baseline/4; + + & > span { + color: $gray-l4; + } + + a { + .fa-facebook-square { + color: $facebook-blue; + } + + .fa-twitter-square { + color: $twitter-blue; + } + + .fa-linkedin-square { + color: $linkedin-blue; + } + } + } .u-field { @extend %t-weight4; - @include padding(0, 0, 0, 3px); + padding: 0; color: $base-font-color; margin-top: $baseline/5; @@ -297,18 +324,19 @@ .wrapper-profile-section-container-two { @include float(left); - width: calc(100% - 360px); + @include padding-left($baseline); + width: calc(100% - 380px); max-width: $learner-profile-container-flex; // Switch to map-get($grid-breakpoints,md) for bootstrap - padding-left: $baseline; @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap + @include padding-left(0); width: 100%; margin-top: $baseline; } .u-field-textarea { margin-bottom: ($baseline/2); - padding: 0 ($baseline*.75) ($baseline*.75) ($baseline/4); + @include padding(0, ($baseline*.75), ($baseline*.75), ($baseline/4)); .u-field-header { position: relative; diff --git a/lms/static/sass/views/_account-settings.scss b/lms/static/sass/views/_account-settings.scss index 67a5d4759b..8cbdd9df0c 100644 --- a/lms/static/sass/views/_account-settings.scss +++ b/lms/static/sass/views/_account-settings.scss @@ -94,7 +94,7 @@ .account-settings-sections { .section-header { - @extend %t-title6; + @extend %t-title5; @extend %t-strong; padding-top: ($baseline/2)*3; color: $dark-gray1; @@ -102,14 +102,13 @@ .section { background-color: $white; - margin-top: $baseline; + margin: $baseline 5% 0; border-bottom: 4px solid $m-gray-l4; .account-settings-header-subtitle { - font-size: em(18); + font-size: em(14); line-height: normal; color: $dark-gray; - padding-top: 20px; padding-bottom: 10px; } @@ -117,6 +116,7 @@ .u-field { border-bottom: 2px solid $m-gray-l4; + padding: $baseline*0.75 0; .field { width: 30%; @@ -301,7 +301,8 @@ .u-field-message { position: relative; - padding: 24px 0 0 ($baseline*5); + padding: $baseline*0.75 0 0 ($baseline*4); + width: 60%; .u-field-message-notification { position: absolute; diff --git a/lms/templates/fields/field_text_account.underscore b/lms/templates/fields/field_text_account.underscore index 18133ea2eb..0c4f8a1ae1 100644 --- a/lms/templates/fields/field_text_account.underscore +++ b/lms/templates/fields/field_text_account.underscore @@ -1,6 +1,6 @@
- +
diff --git a/lms/templates/student_account/account_settings.html b/lms/templates/student_account/account_settings.html index 5c92f0577c..de271d908c 100644 --- a/lms/templates/student_account/account_settings.html +++ b/lms/templates/student_account/account_settings.html @@ -37,7 +37,8 @@ from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_str authData = ${ auth | n, dump_js_escaped_json }, platformName = '${ static.get_platform_name() | n, js_escaped_string }', contactEmail = '${ static.get_contact_email_address() | n, js_escaped_string }', - allowEmailChange = ${ bool(settings.FEATURES['ALLOW_EMAIL_ADDRESS_CHANGE']) | n, dump_js_escaped_json }; + allowEmailChange = ${ bool(settings.FEATURES['ALLOW_EMAIL_ADDRESS_CHANGE']) | n, dump_js_escaped_json }, + socialPlatforms = ${ settings.SOCIAL_PLATFORMS | n, dump_js_escaped_json }; AccountSettingsFactory( fieldsData, @@ -49,7 +50,8 @@ from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_str ${ user.id | n, dump_js_escaped_json }, platformName, contactEmail, - allowEmailChange + allowEmailChange, + socialPlatforms ); diff --git a/lms/templates/student_account/account_settings_section.underscore b/lms/templates/student_account/account_settings_section.underscore index f99d41f278..11bbbfef82 100644 --- a/lms/templates/student_account/account_settings_section.underscore +++ b/lms/templates/student_account/account_settings_section.underscore @@ -3,10 +3,10 @@ <% _.each(sections, function(section) { %>
+

<%- gettext(section.title) %>

<% if (section.subtitle) { %> <% } %> -

<%- gettext(section.title) %>