diff --git a/cms/static/js/spec/views/group_configuration_spec.js b/cms/static/js/spec/views/group_configuration_spec.js index 400715a85806..3283726cb23c 100644 --- a/cms/static/js/spec/views/group_configuration_spec.js +++ b/cms/static/js/spec/views/group_configuration_spec.js @@ -123,7 +123,7 @@ define([ patchAndVerifyRequest(requests, url, notificationSpy); - AjaxHelpers.respondToDelete(requests); + AjaxHelpers.respondWithNoContent(requests); ViewHelpers.verifyNotificationHidden(notificationSpy); expect($(SELECTORS.itemView)).not.toExist(); }; diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js index 98d55a0dd7e6..c50e7fb985b6 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -281,7 +281,7 @@ define(["jquery", "sinon", "js/common_helpers/ajax_helpers", "js/views/utils/vie expect($('.wrapper-alert-announcement')).not.toHaveClass('is-hidden'); $('.dismiss-button').click(); AjaxHelpers.expectJsonRequest(requests, 'DELETE', 'dummy_dismiss_url'); - AjaxHelpers.respondToDelete(requests); + AjaxHelpers.respondWithNoContent(requests); expect($('.wrapper-alert-announcement')).toHaveClass('is-hidden'); }); }); diff --git a/cms/static/js/spec/views/pages/index_spec.js b/cms/static/js/spec/views/pages/index_spec.js index 3217df1b2cc8..622cff39db20 100644 --- a/cms/static/js/spec/views/pages/index_spec.js +++ b/cms/static/js/spec/views/pages/index_spec.js @@ -33,7 +33,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper var reloadSpy = spyOn(ViewUtils, 'reload'); $('.dismiss-button').click(); AjaxHelpers.expectJsonRequest(requests, 'DELETE', 'dummy_dismiss_url'); - AjaxHelpers.respondToDelete(requests); + AjaxHelpers.respondWithNoContent(requests); expect(reloadSpy).toHaveBeenCalled(); }); diff --git a/common/djangoapps/student/migrations/0047_add_bio_field.py b/common/djangoapps/student/migrations/0047_add_bio_field.py index ba1bfc1a9863..33f42d2e6573 100644 --- a/common/djangoapps/student/migrations/0047_add_bio_field.py +++ b/common/djangoapps/student/migrations/0047_add_bio_field.py @@ -10,7 +10,7 @@ class Migration(SchemaMigration): def forwards(self, orm): # Adding field 'UserProfile.bio' db.add_column('auth_userprofile', 'bio', - self.gf('django.db.models.fields.TextField')(null=True, blank=True), + self.gf('django.db.models.fields.CharField')(max_length=3000, null=True, blank=True, db_index=False), keep_default=False) @@ -152,7 +152,7 @@ def backwards(self, orm): 'student.userprofile': { 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'bio': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'bio': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'null': 'True', 'blank': 'True'}), 'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}), 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), diff --git a/common/djangoapps/student/migrations/0048_add_profile_image_version.py b/common/djangoapps/student/migrations/0048_add_profile_image_version.py index e842a06bfcaa..171e3ce55a23 100644 --- a/common/djangoapps/student/migrations/0048_add_profile_image_version.py +++ b/common/djangoapps/student/migrations/0048_add_profile_image_version.py @@ -152,7 +152,7 @@ def backwards(self, orm): 'student.userprofile': { 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'bio': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'bio': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'null': 'True', 'blank': 'True'}), 'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}), 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), diff --git a/common/djangoapps/student/migrations/0049_auto__add_languageproficiency__add_unique_languageproficiency_code_use.py b/common/djangoapps/student/migrations/0049_auto__add_languageproficiency__add_unique_languageproficiency_code_use.py index 47c536afe378..0fcaf252112b 100644 --- a/common/djangoapps/student/migrations/0049_auto__add_languageproficiency__add_unique_languageproficiency_code_use.py +++ b/common/djangoapps/student/migrations/0049_auto__add_languageproficiency__add_unique_languageproficiency_code_use.py @@ -167,7 +167,7 @@ def backwards(self, orm): 'student.userprofile': { 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'bio': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'bio': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'null': 'True', 'blank': 'True'}), 'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}), 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index e82b8eeb99db..b495d8b63c0c 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -250,7 +250,7 @@ class Meta: # pylint: disable=missing-docstring country = CountryField(blank=True, null=True) goals = models.TextField(blank=True, null=True) allow_certificate = models.BooleanField(default=1) - bio = models.TextField(blank=True, null=True) + bio = models.CharField(blank=True, null=True, max_length=3000, db_index=False) profile_image_uploaded_at = models.DateTimeField(null=True) @property diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py index 6d04d3143504..c18952113981 100644 --- a/common/djangoapps/student/tests/test_email.py +++ b/common/djangoapps/student/tests/test_email.py @@ -5,7 +5,8 @@ from student.tests.factories import UserFactory, RegistrationFactory, PendingEmailChangeFactory from student.views import ( - reactivation_email_for_user, change_email_request, do_email_change_request, confirm_email_change + reactivation_email_for_user, change_email_request, do_email_change_request, confirm_email_change, + SETTING_CHANGE_INITIATED ) from student.models import UserProfile, PendingEmailChange from django.core.urlresolvers import reverse @@ -19,6 +20,7 @@ from edxmako.shortcuts import render_to_string from edxmako.tests import mako_middleware_process_request from util.request import safe_get_host +from util.testing import EventTestMixin class TestException(Exception): @@ -198,10 +200,11 @@ def test_reactivation_email_success(self, email_user): self.assertTrue(response_data['success']) -class EmailChangeRequestTests(TestCase): +class EmailChangeRequestTests(EventTestMixin, TestCase): """Test changing a user's email address""" def setUp(self): + super(EmailChangeRequestTests, self).setUp('student.views.tracker') self.user = UserFactory.create() self.new_email = 'new.email@edx.org' self.req_factory = RequestFactory() @@ -275,6 +278,7 @@ def test_email_failure(self, send_mail): send_mail.side_effect = [Exception, None] self.request.POST['new_email'] = "valid@email.com" self.assertFailedRequest(self.run_request(), 'Unable to send email activation link. Please try again later.') + self.assert_no_events_were_emitted() @patch('django.core.mail.send_mail') @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) @@ -295,6 +299,9 @@ def test_email_success(self, send_mail): settings.DEFAULT_FROM_EMAIL, [new_email] ) + self.assert_event_emitted( + SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'email', old=old_email, new=new_email + ) @patch('django.contrib.auth.models.User.email_user') diff --git a/common/djangoapps/student/tests/test_reset_password.py b/common/djangoapps/student/tests/test_reset_password.py index e3305a3037ee..d4cfca5d192b 100644 --- a/common/djangoapps/student/tests/test_reset_password.py +++ b/common/djangoapps/student/tests/test_reset_password.py @@ -17,20 +17,22 @@ from mock import Mock, patch import ddt -from student.views import password_reset, password_reset_confirm_wrapper +from student.views import password_reset, password_reset_confirm_wrapper, SETTING_CHANGE_INITIATED from student.tests.factories import UserFactory from student.tests.test_email import mock_render_to_string +from util.testing import EventTestMixin from test_microsite import fake_site_name @ddt.ddt -class ResetPasswordTests(TestCase): +class ResetPasswordTests(EventTestMixin, TestCase): """ Tests that clicking reset password sends email, and doesn't activate the user """ request_factory = RequestFactory() def setUp(self): + super(ResetPasswordTests, self).setUp('student.views.tracker') self.user = UserFactory.create() self.user.is_active = False self.user.save() @@ -55,6 +57,7 @@ def test_user_bad_password_reset(self): 'success': True, 'value': "('registration/password_reset_done.html', [])", }) + self.assert_no_events_were_emitted() @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_nonexist_email_password_reset(self): @@ -71,6 +74,7 @@ def test_nonexist_email_password_reset(self): 'success': True, 'value': "('registration/password_reset_done.html', [])", }) + self.assert_no_events_were_emitted() @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_password_reset_ratelimited(self): @@ -88,6 +92,7 @@ def test_password_reset_ratelimited(self): bad_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'}) bad_resp = password_reset(bad_req) self.assertEquals(bad_resp.status_code, 403) + self.assert_no_events_were_emitted() cache.clear() @@ -98,6 +103,7 @@ def test_reset_password_email(self, send_email): """Tests contents of reset password email, and that user is not active""" good_req = self.request_factory.post('/password_reset/', {'email': self.user.email}) + good_req.user = self.user good_resp = password_reset(good_req) self.assertEquals(good_resp.status_code, 200) obj = json.loads(good_resp.content) @@ -113,6 +119,10 @@ def test_reset_password_email(self, send_email): self.assertEquals(len(to_addrs), 1) self.assertIn(self.user.email, to_addrs) + self.assert_event_emitted( + SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None, + ) + #test that the user is not active self.user = User.objects.get(pk=self.user.pk) self.assertFalse(self.user.is_active) @@ -130,12 +140,17 @@ def test_reset_password_email_https(self, is_secure, protocol, send_email): '/password_reset/', {'email': self.user.email} ) req.is_secure = Mock(return_value=is_secure) - resp = password_reset(req) + req.user = self.user + password_reset(req) _, msg, _, _ = send_email.call_args[0] expected_msg = "Please go to the following page and choose a new password:\n\n" + protocol self.assertIn(expected_msg, msg) + self.assert_event_emitted( + SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None + ) + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS") @patch('django.core.mail.send_mail') @ddt.data(('Crazy Awesome Site', 'Crazy Awesome Site'), (None, 'edX')) @@ -150,7 +165,8 @@ def test_reset_password_email_domain(self, domain_override, platform_name, send_ '/password_reset/', {'email': self.user.email} ) req.get_host = Mock(return_value=domain_override) - resp = password_reset(req) + req.user = self.user + password_reset(req) _, msg, _, _ = send_email.call_args[0] reset_msg = "you requested a password reset for your user account at {}" @@ -164,6 +180,10 @@ def test_reset_password_email_domain(self, domain_override, platform_name, send_ sign_off = "The {} Team".format(platform_name) self.assertIn(sign_off, msg) + self.assert_event_emitted( + SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None + ) + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS") @patch("microsite_configuration.microsite.get_value", fake_site_name) @patch('django.core.mail.send_mail') @@ -176,13 +196,18 @@ def test_reset_password_email_microsite(self, send_email): '/password_reset/', {'email': self.user.email} ) req.get_host = Mock(return_value=None) - resp = password_reset(req) + req.user = self.user + password_reset(req) _, msg, _, _ = send_email.call_args[0] reset_msg = "you requested a password reset for your user account at openedx.localhost" self.assertIn(reset_msg, msg) + self.assert_event_emitted( + SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None + ) + @patch('student.views.password_reset_confirm') def test_reset_password_bad_token(self, reset_confirm): """Tests bad token and uidb36 in password reset""" diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 891593478966..c27356681bad 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -129,6 +129,8 @@ ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=invalid-name +SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated' + def csrf_token(context): """A csrf token that can be included in a form.""" @@ -620,40 +622,11 @@ def dashboard(request): enrolled_courses_either_paid = frozenset(course.id for course, _enrollment in course_enrollment_pairs if _enrollment.is_paid_course()) - # get info w.r.t ExternalAuthMap - external_auth_map = None - try: - external_auth_map = ExternalAuthMap.objects.get(user=user) - except ExternalAuthMap.DoesNotExist: - pass # If there are *any* denied reverifications that have not been toggled off, # we'll display the banner denied_banner = any(item.display for item in reverifications["denied"]) - language_options = DarkLangConfig.current().released_languages_list - - # add in the default language if it's not in the list of released languages - if settings.LANGUAGE_CODE not in language_options: - language_options.append(settings.LANGUAGE_CODE) - # Re-alphabetize language options - language_options.sort() - - # try to get the preferred language for the user - preferred_language_code = preferences_api.get_user_preference(request.user, LANGUAGE_KEY) - # try and get the current language of the user - current_language_code = get_language() - if preferred_language_code and preferred_language_code in settings.LANGUAGE_DICT: - # if the user has a preference, get the name from the code - current_language = settings.LANGUAGE_DICT[preferred_language_code] - elif current_language_code in settings.LANGUAGE_DICT: - # if the user's browser is showing a particular language, - # use that as the current language - current_language = settings.LANGUAGE_DICT[current_language_code] - else: - # otherwise, use the default language - current_language = settings.LANGUAGE_DICT[settings.LANGUAGE_CODE] - # Populate the Order History for the side-bar. order_history_list = order_history(user, course_org_filter=course_org_filter, org_filter_out_set=org_filter_out_set) @@ -678,7 +651,6 @@ def dashboard(request): 'course_enrollment_pairs': course_enrollment_pairs, 'course_optouts': course_optouts, 'message': message, - 'external_auth_map': external_auth_map, 'staff_access': staff_access, 'errored_courses': errored_courses, 'show_courseware_links_for': show_courseware_links_for, @@ -693,11 +665,7 @@ def dashboard(request): 'block_courses': block_courses, 'denied_banner': denied_banner, 'billing_email': settings.PAYMENT_SUPPORT_EMAIL, - 'language_options': language_options, - 'current_language': current_language, - 'current_language_code': current_language_code, 'user': user, - 'duplicate_provider': None, 'logout_url': reverse(logout_user), 'platform_name': platform_name, 'enrolled_courses_either_paid': enrolled_courses_either_paid, @@ -707,10 +675,6 @@ def dashboard(request): 'ccx_membership_triplets': ccx_membership_triplets, } - if third_party_auth.is_enabled(): - context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request)) - context['provider_user_states'] = pipeline.get_provider_user_states(user) - return render_to_response('dashboard.html', context) @@ -1871,6 +1835,18 @@ def password_reset(request): from_email=settings.DEFAULT_FROM_EMAIL, request=request, domain_override=request.get_host()) + # When password change is complete, a "edx.user.settings.changed" event will be emitted. + # But because changing the password is multi-step, we also emit an event here so that we can + # track where the request was initiated. + tracker.emit( + SETTING_CHANGE_INITIATED, + { + "setting": "password", + "old": None, + "new": None, + "user_id": request.user.id, + } + ) else: # bad user? tick the rate limiter counter AUDIT_LOG.info("Bad password_reset user passed in.") @@ -2087,6 +2063,19 @@ def do_email_change_request(user, new_email, activation_key=uuid.uuid4().hex): log.error(u'Unable to send email activation link to user from "%s"', from_address, exc_info=True) raise ValueError(_('Unable to send email activation link. Please try again later.')) + # When the email address change is complete, a "edx.user.settings.changed" event will be emitted. + # But because changing the email address is multi-step, we also emit an event here so that we can + # track where the request was initiated. + tracker.emit( + SETTING_CHANGE_INITIATED, + { + "setting": "email", + "old": context['old_email'], + "new": context['new_email'], + "user_id": user.id, + } + ) + @ensure_csrf_cookie @transaction.commit_manually diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 58f415d3d1d8..2ff7ea67b05d 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -114,9 +114,9 @@ def B(*args, **kwargs): # The following are various possible values for the AUTH_ENTRY_KEY. -AUTH_ENTRY_DASHBOARD = 'dashboard' AUTH_ENTRY_LOGIN = 'login' AUTH_ENTRY_REGISTER = 'register' +AUTH_ENTRY_ACCOUNT_SETTINGS = 'account_settings' # This is left-over from an A/B test # of the new combined login/registration page (ECOM-369) @@ -142,9 +142,9 @@ def is_api(auth_entry): # We don't use "reverse" here because doing so may cause modules # to load that depend on this module. AUTH_DISPATCH_URLS = { - AUTH_ENTRY_DASHBOARD: '/dashboard', AUTH_ENTRY_LOGIN: '/login', AUTH_ENTRY_REGISTER: '/register', + AUTH_ENTRY_ACCOUNT_SETTINGS: '/account/settings', # This is left-over from an A/B test # of the new combined login/registration page (ECOM-369) @@ -156,9 +156,9 @@ def is_api(auth_entry): } _AUTH_ENTRY_CHOICES = frozenset([ - AUTH_ENTRY_DASHBOARD, AUTH_ENTRY_LOGIN, AUTH_ENTRY_REGISTER, + AUTH_ENTRY_ACCOUNT_SETTINGS, # This is left-over from an A/B test # of the new combined login/registration page (ECOM-369) @@ -577,7 +577,7 @@ def login_analytics(strategy, auth_entry, *args, **kwargs): event_name = None if auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]: event_name = 'edx.bi.user.account.authenticated' - elif auth_entry in [AUTH_ENTRY_DASHBOARD]: + elif auth_entry in [AUTH_ENTRY_ACCOUNT_SETTINGS]: event_name = 'edx.bi.user.account.linked' if event_name is not None: @@ -623,7 +623,7 @@ def change_enrollment(strategy, auth_entry=None, user=None, *args, **kwargs): user (User): The user being authenticated. """ # We skip enrollment if the user entered the flow from the "link account" - # button on the student dashboard. At this point, either: + # button on the account settings page. At this point, either: # # 1) The user already had a linked account when they started the enrollment flow, # in which case they would have been enrolled during the normal authentication process. @@ -633,7 +633,7 @@ def change_enrollment(strategy, auth_entry=None, user=None, *args, **kwargs): # args when sending users to this page, successfully authenticating through this page # would also enroll the student in the course. enroll_course_id = strategy.session_get('enroll_course_id') - if enroll_course_id and auth_entry != AUTH_ENTRY_DASHBOARD: + if enroll_course_id and auth_entry != AUTH_ENTRY_ACCOUNT_SETTINGS: course_id = CourseKey.from_string(enroll_course_id) modes = CourseMode.modes_for_course_dict(course_id) diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 11b27252a162..902a1beb1d12 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -19,6 +19,7 @@ from social.apps.django_app import views as social_views from student import models as student_models from student import views as student_views +from student_account.views import account_settings_context from third_party_auth import middleware, pipeline from third_party_auth import settings as auth_settings @@ -110,41 +111,25 @@ def setUp(self): self.client = test.Client() self.request_factory = test.RequestFactory() - def assert_dashboard_response_looks_correct(self, response, user, duplicate=False, linked=None): - """Asserts the user's dashboard is in the expected state. + def assert_account_settings_context_looks_correct(self, context, user, duplicate=False, linked=None): + """Asserts the user's account settings page context is in the expected state. - We check unconditionally that the dashboard 200s and contains the - user's info. If duplicate is True, we expect the duplicate account - association error to be present. If linked is passed, we conditionally - check the content and controls in the Account Links section of the - sidebar. + If duplicate is True, we expect context['duplicate_provider'] to contain + the duplicate provider object. If linked is passed, we conditionally + check that the provider is included in context['auth']['providers'] and + its connected state is correct. """ - duplicate_account_error_needle = '
' - assert_duplicate_presence_fn = self.assertIn if duplicate else self.assertNotIn - - self.assertEqual(200, response.status_code) - self.assertIn(user.email, response.content.decode('UTF-8')) - self.assertIn(user.username, response.content.decode('UTF-8')) - assert_duplicate_presence_fn(duplicate_account_error_needle, response.content) + if duplicate: + self.assertEqual(context['duplicate_provider'].NAME, self.PROVIDER_CLASS.NAME) + else: + self.assertIsNone(context['duplicate_provider']) if linked is not None: - - if linked: - expected_control_text = pipeline.ProviderUserState( - self.PROVIDER_CLASS, user, False).get_unlink_form_name() - else: - expected_control_text = pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_DASHBOARD) - - provider_name = re.search(r'([^<]+)', response.content, re.DOTALL).groups()[0] - - self.assertIn(expected_control_text, response.content) - if linked: - self.assertIn("fa fa-link", response.content) - self.assertNotIn("fa fa-unlink", response.content) - else: - self.assertNotIn("fa fa-link", response.content) - self.assertIn("fa fa-unlink", response.content) - self.assertEqual(self.PROVIDER_CLASS.NAME, provider_name) + expected_provider = [ + provider for provider in context['auth']['providers'] if provider['name'] == self.PROVIDER_CLASS.NAME + ][0] + self.assertIsNotNone(expected_provider) + self.assertEqual(expected_provider['connected'], linked) def assert_exception_redirect_looks_correct(self, expected_uri, auth_entry=None): """Tests middleware conditional redirection. @@ -406,6 +391,11 @@ def test_canceling_authentication_redirects_to_login_when_auth_login_2(self): def test_canceling_authentication_redirects_to_login_when_auth_register_2(self): self.assert_exception_redirect_looks_correct('/account/register/', auth_entry=pipeline.AUTH_ENTRY_REGISTER_2) + def test_canceling_authentication_redirects_to_account_settings_when_auth_entry_account_settings(self): + self.assert_exception_redirect_looks_correct( + '/account/settings', auth_entry=pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS + ) + def test_canceling_authentication_redirects_to_root_when_auth_entry_not_set(self): self.assert_exception_redirect_looks_correct('/') @@ -432,7 +422,7 @@ def test_full_pipeline_succeeds_for_linking_account(self): # First we expect that we're in the unlinked state, and that there # really is no association in the backend. - self.assert_dashboard_response_looks_correct(student_views.dashboard(request), request.user, linked=False) + self.assert_account_settings_context_looks_correct(account_settings_context(request), request.user, linked=False) self.assert_social_auth_does_not_exist_for_user(request.user, strategy) # We should be redirected back to the complete page, setting @@ -452,7 +442,7 @@ def test_full_pipeline_succeeds_for_linking_account(self): # Now we expect to be in the linked state, with a backend entry. self.assert_social_auth_exists_for_user(request.user, strategy) - self.assert_dashboard_response_looks_correct(student_views.dashboard(request), request.user, linked=True) + self.assert_account_settings_context_looks_correct(account_settings_context(request), request.user, linked=True) def test_full_pipeline_succeeds_for_unlinking_account(self): # First, create, the request and strategy that store pipeline state, @@ -479,7 +469,7 @@ def test_full_pipeline_succeeds_for_unlinking_account(self): actions.do_complete(strategy, social_views._do_login, user=user) # pylint: disable-msg=protected-access # First we expect that we're in the linked state, with a backend entry. - self.assert_dashboard_response_looks_correct(student_views.dashboard(request), user, linked=True) + self.assert_account_settings_context_looks_correct(account_settings_context(request), user, linked=True) self.assert_social_auth_exists_for_user(request.user, strategy) # Fire off the disconnect pipeline to unlink. @@ -487,7 +477,7 @@ def test_full_pipeline_succeeds_for_unlinking_account(self): request.social_strategy, request.user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME)) # Now we expect to be in the unlinked state, with no backend entry. - self.assert_dashboard_response_looks_correct(student_views.dashboard(request), user, linked=False) + self.assert_account_settings_context_looks_correct(account_settings_context(request), user, linked=False) self.assert_social_auth_does_not_exist_for_user(user, strategy) def test_linking_already_associated_account_raises_auth_already_associated(self): @@ -541,8 +531,8 @@ def test_already_associated_exception_populates_dashboard_with_error(self): request, exceptions.AuthAlreadyAssociated(self.PROVIDER_CLASS.BACKEND_CLASS.name, 'account is already in use.')) - self.assert_dashboard_response_looks_correct( - student_views.dashboard(request), user, duplicate=True, linked=True) + self.assert_account_settings_context_looks_correct( + account_settings_context(request), user, duplicate=True, linked=True) def test_full_pipeline_succeeds_for_signing_in_to_existing_active_account(self): # First, create, the request and strategy that store pipeline state, @@ -593,7 +583,7 @@ def test_full_pipeline_succeeds_for_signing_in_to_existing_active_account(self): self.assert_redirect_to_dashboard_looks_correct( actions.do_complete(strategy, social_views._do_login, user=user)) - self.assert_dashboard_response_looks_correct(student_views.dashboard(request), user) + self.assert_account_settings_context_looks_correct(account_settings_context(request), user) def test_signin_fails_if_account_not_active(self): _, strategy = self.get_request_and_strategy( @@ -710,7 +700,7 @@ def test_full_pipeline_succeeds_registering_new_account(self): self.assert_redirect_to_dashboard_looks_correct( actions.do_complete(strategy, social_views._do_login, user=created_user)) self.assert_social_auth_exists_for_user(created_user, strategy) - self.assert_dashboard_response_looks_correct(student_views.dashboard(request), created_user, linked=True) + self.assert_account_settings_context_looks_correct(account_settings_context(request), created_user, linked=True) def test_new_account_registration_assigns_distinct_username_on_collision(self): original_username = self.get_username() diff --git a/common/djangoapps/third_party_auth/tests/test_change_enrollment.py b/common/djangoapps/third_party_auth/tests/test_change_enrollment.py index caf71e5da130..3fa46556738f 100644 --- a/common/djangoapps/third_party_auth/tests/test_change_enrollment.py +++ b/common/djangoapps/third_party_auth/tests/test_change_enrollment.py @@ -145,9 +145,9 @@ def test_skip_enroll_from_dashboard(self): strategy = self._fake_strategy() strategy.session_set('enroll_course_id', unicode(self.course.id)) - # Simulate completing the pipeline from the student dashboard's + # Simulate completing the pipeline from the student account settings # "link account" button. - result = pipeline.change_enrollment(strategy, 1, user=self.user, auth_entry=pipeline.AUTH_ENTRY_DASHBOARD) # pylint: disable=assignment-from-no-return,redundant-keyword-arg + result = pipeline.change_enrollment(strategy, 1, user=self.user, auth_entry=pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS) # pylint: disable=assignment-from-no-return,redundant-keyword-arg # Verify that we were NOT enrolled self.assertEqual(result, {}) diff --git a/common/djangoapps/util/testing.py b/common/djangoapps/util/testing.py index ccfd29e91d09..72a94a93cdac 100644 --- a/common/djangoapps/util/testing.py +++ b/common/djangoapps/util/testing.py @@ -1,5 +1,7 @@ import sys +from mock import patch + from django.conf import settings from django.core.urlresolvers import clear_url_caches, resolve @@ -55,3 +57,36 @@ def setUp(self, *args, **kwargs): self._reset_urls(urlconf_modules) self.addCleanup(lambda: self._reset_urls(urlconf_modules)) + + +class EventTestMixin(object): + """ + Generic mixin for verifying that events were emitted during a test. + """ + def setUp(self, tracker): + super(EventTestMixin, self).setUp() + self.tracker = tracker + patcher = patch(self.tracker) + self.mock_tracker = patcher.start() + self.addCleanup(patcher.stop) + + def assert_no_events_were_emitted(self): + """ + Ensures no events were emitted since the last event related assertion. + """ + self.assertFalse(self.mock_tracker.emit.called) # pylint: disable=maybe-no-member + + def assert_event_emitted(self, event_name, **kwargs): + """ + Verify that an event was emitted with the given parameters. + """ + self.mock_tracker.emit.assert_any_call( # pylint: disable=maybe-no-member + event_name, + kwargs + ) + + def reset_tracker(self): + """ + Reset the mock tracker in order to forget about old events. + """ + self.mock_tracker.reset_mock() diff --git a/common/static/js/spec_helpers/ajax_helpers.js b/common/static/js/spec_helpers/ajax_helpers.js index f2be1a44fb8d..6ceda7efcc6d 100644 --- a/common/static/js/spec_helpers/ajax_helpers.js +++ b/common/static/js/spec_helpers/ajax_helpers.js @@ -1,6 +1,6 @@ define(['sinon', 'underscore'], function(sinon, _) { var fakeServer, fakeRequests, expectRequest, expectJsonRequest, - respondWithJson, respondWithError, respondWithTextError, respondToDelete; + respondWithJson, respondWithError, respondWithTextError, responseWithNoContent; /* These utility methods are used by Jasmine tests to create a mock server or * get reference to mock requests. In either case, the cleanup (restore) is done with @@ -109,7 +109,7 @@ define(['sinon', 'underscore'], function(sinon, _) { ); }; - respondToDelete = function(requests, requestIndex) { + respondWithNoContent = function(requests, requestIndex) { if (_.isUndefined(requestIndex)) { requestIndex = requests.length - 1; } @@ -125,6 +125,6 @@ define(['sinon', 'underscore'], function(sinon, _) { 'respondWithJson': respondWithJson, 'respondWithError': respondWithError, 'respondWithTextError': respondWithTextError, - 'respondToDelete': respondToDelete + 'respondWithNoContent': respondWithNoContent, }; }); diff --git a/common/test/acceptance/pages/lms/account_settings.py b/common/test/acceptance/pages/lms/account_settings.py new file mode 100644 index 000000000000..4e55e8479c75 --- /dev/null +++ b/common/test/acceptance/pages/lms/account_settings.py @@ -0,0 +1,61 @@ +""" +Base class for account settings page. +""" +from . import BASE_URL + +from bok_choy.page_object import PageObject +from bok_choy.promise import EmptyPromise + +from .fields import FieldsMixin + + +class AccountSettingsPage(FieldsMixin, PageObject): + """ + Tests for Account Settings Page. + """ + + url = "{base}/{settings}".format(base=BASE_URL, settings='account/settings') + + def is_browser_on_page(self): + return 'Account Settings' in self.browser.title + + def sections_structure(self): + """ + Return list of section titles and field titles for each section. + + Example: [ + { + 'title': 'Section Title' + 'fields': ['Field 1 title', 'Field 2 title',...] + }, + ... + ] + """ + self.wait_for_ajax() + + structure = [] + + sections = self.q(css='.section') + for section in sections: + section_title_element = section.find_element_by_class_name('section-header') + field_title_elements = section.find_elements_by_class_name('u-field-title') + + structure.append({ + 'title': section_title_element.text, + 'fields': [element.text for element in field_title_elements], + }) + + return structure + + def _is_loading_in_progress(self): + """ + Check if loading indicator is visible. + """ + query = self.q(css='.ui-loading-indicator') + return query.present and 'is-hidden' not in query.attrs('class')[0].split() + + def wait_for_loading_indicator(self): + """ + Wait for loading indicator to become visible. + """ + EmptyPromise(self._is_loading_in_progress, "Loading is in progress.").fulfill() diff --git a/common/test/acceptance/pages/lms/dashboard.py b/common/test/acceptance/pages/lms/dashboard.py index e1954d13f234..84bc851b7ae0 100644 --- a/common/test/acceptance/pages/lms/dashboard.py +++ b/common/test/acceptance/pages/lms/dashboard.py @@ -50,19 +50,18 @@ def _get_course_name(el): return self.q(css='h3.course-title > a').map(_get_course_name).results @property - def full_name(self): - """Return the displayed value for the user's full name""" - return self.q(css='li.info--username .data').text[0] - - @property - def email(self): - """Return the displayed value for the user's email address""" - return self.q(css='li.info--email .data').text[0] + def sidebar_menu_title(self): + """ + Return the title value for sidebar menu. + """ + return self.q(css='.user-info span.title').text[0] @property - def username(self): - """Return the displayed value for the user's username""" - return self.q(css='.username-label').text[0] + def sidebar_menu_description(self): + """ + Return the description text for sidebar menu. + """ + return self.q(css='.user-info span.copy').text[0] def get_enrollment_mode(self, course_name): """Get the enrollment mode for a given course on the dashboard. @@ -149,27 +148,6 @@ def _link_css(self, course_id): else: return None - def change_language(self, code): - """ - Change the language on the dashboard to the language corresponding with `code`. - """ - self.q(css=".edit-language").first.click() - self.q(css='select[name="language"] option[value="{}"]'.format(code)).first.click() - self.q(css="#submit-lang").first.click() - - # Clicking the submit-lang button does a jquery ajax post, so make sure that - # has completed before continuing on. - self.wait_for_ajax() - - self._changed_lang_promise(code).fulfill() - - def _changed_lang_promise(self, code): - def _check_func(): - language_is_selected = self.q(css='select[name="language"] option[value="{}"]'.format(code)).selected - modal_is_visible = self.q(css='section#change_language.modal').visible - return (language_is_selected and not modal_is_visible) - return EmptyPromise(_check_func, "language changed and modal hidden") - def pre_requisite_message_displayed(self): """ Verify if pre-requisite course messages are being displayed. @@ -183,3 +161,28 @@ def get_course_listings(self): def get_course_social_sharing_widget(self, widget_name): """ Retrieves the specified social sharing widget by its classification """ return self.q(css='a.action-{}'.format(widget_name)) + + def click_username_dropdown(self): + """ + Click username dropdown. + """ + self.q(css='.dropdown').first.click() + + @property + def username_dropdown_link_text(self): + """ + Return list username dropdown links. + """ + return self.q(css='.dropdown-menu li a').text + + def click_account_settings_link(self): + """ + Click on `Account Settings` link. + """ + self.q(css='.dropdown-menu li a').first.click() + + def click_my_profile_link(self): + """ + Click on `My Profile` link. + """ + self.q(css='.dropdown-menu li a').nth(1).click() diff --git a/common/test/acceptance/pages/lms/discussion.py b/common/test/acceptance/pages/lms/discussion.py index affe1f83e26c..2b2f17d9a855 100644 --- a/common/test/acceptance/pages/lms/discussion.py +++ b/common/test/acceptance/pages/lms/discussion.py @@ -444,9 +444,9 @@ def is_browser_on_page(self): return ( self.q(css='section.discussion-user-threads[data-course-id="{}"]'.format(self.course_id)).present and - self.q(css='section.user-profile div.sidebar-username').present + self.q(css='section.user-profile a.leaner-profile-link').present and - self.q(css='section.user-profile div.sidebar-username').text[0] == self.username + self.q(css='section.user-profile a.leaner-profile-link').text[0] == self.username ) @wait_for_js @@ -526,6 +526,10 @@ def click_on_page(self, page_number): "Window is on top" ).fulfill() + def click_on_sidebar_username(self): + self.wait_for_page() + self.q(css='.leaner-profile-link').first.click() + class DiscussionTabHomePage(CoursePage, DiscussionPageMixin): diff --git a/common/test/acceptance/pages/lms/fields.py b/common/test/acceptance/pages/lms/fields.py new file mode 100644 index 000000000000..54d48dbd1ea1 --- /dev/null +++ b/common/test/acceptance/pages/lms/fields.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +""" +Mixins for fields. +""" +from bok_choy.promise import EmptyPromise + +from ...tests.helpers import get_selected_option_text, select_option_by_text + + +class FieldsMixin(object): + """ + Methods for testing fields in pages. + """ + + def field(self, field_id): + """ + Return field with field_id. + """ + self.wait_for_ajax() + + query = self.q(css='.u-field-{}'.format(field_id)) + return query.text[0] if query.present else None + + def wait_for_field(self, field_id): + """ + Wait for a field to appear in DOM. + """ + EmptyPromise( + lambda: self.field(field_id) is not None, + "Field with id \"{0}\" is in DOM.".format(field_id) + ).fulfill() + + def mode_for_field(self, field_id): + """ + Extract current field mode. + + Returns: + `placeholder`/`edit`/`display` + """ + self.wait_for_field(field_id) + + query = self.q(css='.u-field-{}'.format(field_id)) + + if not query.present: + return None + + field_classes = query.attrs('class')[0].split() + + if 'mode-placeholder' in field_classes: + return 'placeholder' + + if 'mode-display' in field_classes: + return 'display' + + if 'mode-edit' in field_classes: + return 'edit' + + def icon_for_field(self, field_id, icon_id): + """ + Check if field icon is present. + """ + self.wait_for_field(field_id) + + query = self.q(css='.u-field-{} .u-field-icon'.format(field_id)) + return query.present and icon_id in query.attrs('class')[0].split() + + def title_for_field(self, field_id): + """ + Return the title of a field. + """ + self.wait_for_field(field_id) + + query = self.q(css='.u-field-{} .u-field-title'.format(field_id)) + return query.text[0] if query.present else None + + def message_for_field(self, field_id): + """ + Return the current message in a field. + """ + self.wait_for_field(field_id) + + query = self.q(css='.u-field-{} .u-field-message'.format(field_id)) + return query.text[0] if query.present else None + + def wait_for_messsage(self, field_id, message): + """ + Wait for a message to appear in a field. + """ + EmptyPromise( + lambda: message in (self.message_for_field(field_id) or ''), + "Messsage \"{0}\" is visible.".format(message) + ).fulfill() + + def indicator_for_field(self, field_id): + """ + Return the name of the current indicator in a field. + """ + self.wait_for_field(field_id) + + query = self.q(css='.u-field-{} .u-field-message i'.format(field_id)) + return [ + class_name for class_name + in query.attrs('class')[0].split(' ') + if class_name.startswith('message') + ][0].partition('-')[2] if query.present else None + + def wait_for_indicator(self, field_id, indicator): + """ + Wait for an indicator to appear in a field. + """ + EmptyPromise( + lambda: indicator == self.indicator_for_field(field_id), + "Indicator \"{0}\" is visible.".format(self.indicator_for_field(field_id)) + ).fulfill() + + def make_field_editable(self, field_id): + """ + Make a field editable. + """ + query = self.q(css='.u-field-{}'.format(field_id)) + + if not query.present: + return None + + field_classes = query.attrs('class')[0].split() + + if 'mode-placeholder' in field_classes or 'mode-display' in field_classes: + if field_id == 'bio': + self.q(css='.u-field-bio > .wrapper-u-field').first.click() + else: + self.q(css='.u-field-{}'.format(field_id)).first.click() + + def value_for_readonly_field(self, field_id): + """ + Return the value in a readonly field. + """ + self.wait_for_field(field_id) + + return self.value_for_text_field(field_id) + + def value_for_text_field(self, field_id, value=None): + """ + Get or set the value of a text field. + """ + self.wait_for_field(field_id) + + query = self.q(css='.u-field-{} input'.format(field_id)) + if not query.present: + return None + + if value is not None: + current_value = query.attrs('value')[0] + query.results[0].send_keys(u'\ue003' * len(current_value)) # Delete existing value. + query.results[0].send_keys(value) # Input new value + query.results[0].send_keys(u'\ue007') # Press Enter + return query.attrs('value')[0] + + def value_for_textarea_field(self, field_id, value=None): + """ + Get or set the value of a textarea field. + """ + self.wait_for_field(field_id) + + self.make_field_editable(field_id) + + query = self.q(css='.u-field-{} textarea'.format(field_id)) + if not query.present: + return None + + if value is not None: + query.fill(value) + query.results[0].send_keys(u'\ue004') # Focus Out using TAB + + if self.mode_for_field(field_id) == 'edit': + return query.text[0] + else: + return self.get_non_editable_mode_value(field_id) + + def get_non_editable_mode_value(self, field_id): + """ + Return value of field in `display` or `placeholder` mode. + """ + self.wait_for_field(field_id) + + return self.q(css='.u-field-{} .u-field-value .u-field-value-readonly'.format(field_id)).text[0] + + def value_for_dropdown_field(self, field_id, value=None): + """ + Get or set the value in a dropdown field. + """ + self.wait_for_field(field_id) + + self.make_field_editable(field_id) + + query = self.q(css='.u-field-{} select'.format(field_id)) + if not query.present: + return None + + if value is not None: + select_option_by_text(query, value) + + if self.mode_for_field(field_id) == 'edit': + return get_selected_option_text(query) + else: + return self.get_non_editable_mode_value(field_id) + + def link_title_for_link_field(self, field_id): + """ + Return the title of the link in a link field. + """ + self.wait_for_field(field_id) + + query = self.q(css='.u-field-link-title-{}'.format(field_id)) + return query.text[0] if query.present else None + + def click_on_link_in_link_field(self, field_id): + """ + Click the link in a link field. + """ + self.wait_for_field(field_id) + + query = self.q(css='.u-field-{} a'.format(field_id)) + if query.present: + query.first.click() diff --git a/common/test/acceptance/pages/lms/learner_profile.py b/common/test/acceptance/pages/lms/learner_profile.py new file mode 100644 index 000000000000..ad211c405cfd --- /dev/null +++ b/common/test/acceptance/pages/lms/learner_profile.py @@ -0,0 +1,275 @@ +""" +Bok-Choy PageObject class for learner profile page. +""" +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="{}"]' +FIELD_ICONS = { + 'country': 'fa-map-marker', + 'language_proficiencies': 'fa-comment', +} + + +class LearnerProfilePage(FieldsMixin, PageObject): + """ + PageObject methods for Learning Profile Page. + """ + + def __init__(self, browser, username): + """ + Initialize the page. + + Arguments: + browser (Browser): The browser instance. + username (str): Profile username. + """ + super(LearnerProfilePage, self).__init__(browser) + self.username = username + + @property + def url(self): + """ + Construct a URL to the page. + """ + return BASE_URL + "/u/" + self.username + + def is_browser_on_page(self): + """ + Check if browser is showing correct page. + """ + return 'Learner Profile' in self.browser.title + + @property + def privacy(self): + """ + Get user profile privacy. + + Returns: + 'all_users' or 'private' + """ + return 'all_users' if self.q(css=PROFILE_VISIBILITY_SELECTOR.format('all_users')).selected else 'private' + + @privacy.setter + def privacy(self, privacy): + """ + Set user profile privacy. + + Arguments: + privacy (str): 'all_users' or 'private' + """ + self.wait_for_element_visibility('select#u-field-select-account_privacy', 'Privacy dropdown is visible') + + if privacy != self.privacy: + self.q(css=PROFILE_VISIBILITY_SELECTOR.format(privacy)).first.click() + EmptyPromise(lambda: privacy == self.privacy, 'Privacy is set to {}'.format(privacy)).fulfill() + self.wait_for_ajax() + + if privacy == 'all_users': + self.wait_for_public_fields() + + def field_is_visible(self, field_id): + """ + Check if a field with id set to `field_id` is shown. + + Arguments: + field_id (str): field id + + Returns: + True/False + """ + self.wait_for_ajax() + return self.q(css='.u-field-{}'.format(field_id)).visible + + def field_is_editable(self, field_id): + """ + Check if a field with id set to `field_id` is editable. + + Arguments: + field_id (str): field id + + Returns: + True/False + """ + self.wait_for_field(field_id) + self.make_field_editable(field_id) + return self.mode_for_field(field_id) == 'edit' + + @property + def visible_fields(self): + """ + Return list of visible fields. + """ + self.wait_for_field('username') + + fields = ['username', 'country', 'language_proficiencies', 'bio'] + return [field for field in fields if self.field_is_visible(field)] + + @property + def editable_fields(self): + """ + Return list of editable fields currently shown on page. + """ + self.wait_for_ajax() + self.wait_for_element_visibility('.u-field-username', 'username is not visible') + + fields = ['country', 'language_proficiencies', 'bio'] + return [field for field in fields if self.field_is_editable(field)] + + @property + def privacy_field_visible(self): + """ + Check if profile visibility selector is shown or not. + + Returns: + True/False + """ + self.wait_for_ajax() + return self.q(css='#u-field-select-account_privacy').visible + + def field_icon_present(self, field_id): + """ + Check if an icon is present for a field. Only dropdown fields have icons. + + Arguments: + field_id (str): field id + + Returns: + True/False + """ + return self.icon_for_field(field_id, FIELD_ICONS[field_id]) + + def wait_for_public_fields(self): + """ + Wait for `country`, `language` and `bio` fields to be visible. + """ + EmptyPromise(lambda: self.field_is_visible('country'), 'Country field is visible').fulfill() + EmptyPromise(lambda: self.field_is_visible('language_proficiencies'), 'Language field is visible').fulfill() + EmptyPromise(lambda: self.field_is_visible('bio'), 'About Me field is visible').fulfill() + + @property + def profile_forced_private_message(self): + """ + Returns age limit message. + """ + self.wait_for_ajax() + return self.q(css='#u-field-message-account_privacy').text[0] + + @property + def age_limit_message_present(self): + """ + Check if age limit message is present. + """ + 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/discussion/test_discussion.py b/common/test/acceptance/tests/discussion/test_discussion.py index 9faa4077c49e..597df3ad3fb1 100644 --- a/common/test/acceptance/tests/discussion/test_discussion.py +++ b/common/test/acceptance/tests/discussion/test_discussion.py @@ -19,6 +19,8 @@ DiscussionTabHomePage, DiscussionSortPreferencePage, ) +from ...pages.lms.learner_profile import LearnerProfilePage + from ...fixtures.course import CourseFixture, XBlockFixtureDesc from ...fixtures.discussion import ( SingleThreadViewFixture, @@ -753,6 +755,24 @@ def test_pagination_window_reposition(self): page.wait_for_ajax() self.assertTrue(page.is_window_on_top()) + def test_redirects_to_learner_profile(self): + """ + Scenario: Verify that learner-profile link is present on forum discussions page and we can navigate to it. + + Given that I am on discussion forum user's profile page. + And I can see a username on left sidebar + When I click on my username. + Then I will be navigated to Learner Profile page. + And I can my username on Learner Profile page + """ + learner_profile_page = LearnerProfilePage(self.browser, self.PROFILED_USERNAME) + + page = self.check_pages(1) + page.click_on_sidebar_username() + + learner_profile_page.wait_for_page() + self.assertTrue(learner_profile_page.field_is_visible('username')) + @attr('shard_1') class DiscussionSearchAlertTest(UniqueCourseTest): diff --git a/common/test/acceptance/tests/helpers.py b/common/test/acceptance/tests/helpers.py index 48204fb575cf..3bd02e5e97a6 100644 --- a/common/test/acceptance/tests/helpers.py +++ b/common/test/acceptance/tests/helpers.py @@ -277,8 +277,7 @@ class EventsTestMixin(object): def setUp(self): super(EventsTestMixin, self).setUp() self.event_collection = MongoClient()["test"]["events"] - self.event_collection.drop() - self.start_time = datetime.now() + self.reset_event_tracking() def assert_event_emitted_num_times(self, event_name, event_time, event_user_id, num_times_emitted): """ @@ -298,6 +297,60 @@ def assert_event_emitted_num_times(self, event_name, event_time, event_user_id, ).count(), num_times_emitted ) + def reset_event_tracking(self): + """ + Resets all event tracking so that previously captured events are removed. + """ + self.event_collection.drop() + self.start_time = datetime.now() + + def get_matching_events(self, event_type): + """ + Returns a cursor for the matching browser events. + """ + return self.event_collection.find({ + "event_type": event_type, + "time": {"$gt": self.start_time}, + }) + + def verify_events_of_type(self, event_type, expected_events, expected_referers=None): + """Verify that the expected events of a given type were logged. + + Args: + event_type (str): The type of event to be verified. + expected_events (list): A list of dicts representing the events that should + have been fired. + expected_referers (list): A list of strings representing the referers for each event + that should been fired (optional). If present, the actual referers compared + with this list, checking that the expected_referers are the suffixes of + actual_referers. For example, if one event is expected, specifying ["/account/settings"] + will verify that the referer for the single event ends with "/account/settings". + """ + EmptyPromise( + lambda: self.get_matching_events(event_type).count() >= len(expected_events), + "Waiting for the minimum number of events of type {type} to have been recorded".format(type=event_type) + ).fulfill() + + # Verify that the correct events were fired + cursor = self.get_matching_events(event_type) + actual_events = [] + actual_referers = [] + for __ in range(0, cursor.count()): + emitted_data = cursor.next() + event = emitted_data["event"] + if emitted_data["event_source"] == "browser": + event = json.loads(event) + actual_events.append(event) + actual_referers.append(emitted_data["referer"]) + self.assertEqual(expected_events, actual_events) + if expected_referers is not None: + self.assertEqual(len(expected_referers), len(actual_referers), "Number of expected referers is incorrect") + for index, actual_referer in enumerate(actual_referers): + self.assertTrue( + actual_referer.endswith(expected_referers[index]), + "Refer '{0}' does not have correct suffix, '{1}'.".format(actual_referer, expected_referers[index]) + ) + class UniqueCourseTest(WebAppTest): """ diff --git a/common/test/acceptance/tests/lms/test_account_settings.py b/common/test/acceptance/tests/lms/test_account_settings.py new file mode 100644 index 000000000000..5aca9f089b9d --- /dev/null +++ b/common/test/acceptance/tests/lms/test_account_settings.py @@ -0,0 +1,348 @@ +# -*- coding: utf-8 -*- +""" +End-to-end tests for the Account Settings page. +""" +from unittest import skip + +from bok_choy.web_app_test import WebAppTest + +from ...pages.lms.account_settings import AccountSettingsPage +from ...pages.lms.auto_auth import AutoAuthPage +from ...pages.lms.dashboard import DashboardPage + +from ..helpers import EventsTestMixin + + +class AccountSettingsTestMixin(EventsTestMixin, WebAppTest): + """ + Mixin with helper methods to test the account settings page. + """ + + USERNAME = "test" + PASSWORD = "testpass" + EMAIL = u"test@example.com" + CHANGE_INITIATED_EVENT_NAME = u"edx.user.settings.change_initiated" + ACCOUNT_SETTINGS_REFERER = u"/account/settings" + + def setUp(self): + """ + Initialize account and pages. + """ + super(AccountSettingsTestMixin, self).setUp() + + self.user_id = AutoAuthPage( + self.browser, username=self.USERNAME, password=self.PASSWORD, email=self.EMAIL + ).visit().get_user_id() + + +class DashboardMenuTest(AccountSettingsTestMixin, WebAppTest): + """ + Tests that the dashboard menu works correctly with the account settings page. + """ + def test_link_on_dashboard_works(self): + """ + Scenario: Verify that the "Account Settings" link works from the dashboard. + + + Given that I am a registered user + And I visit my dashboard + And I click on "Account Settings" in the top drop down + Then I should see my account settings page + """ + dashboard_page = DashboardPage(self.browser) + dashboard_page.visit() + dashboard_page.click_username_dropdown() + self.assertIn('Account Settings', dashboard_page.username_dropdown_link_text) + dashboard_page.click_account_settings_link() + + +class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest): + """ + Tests that verify behaviour of the Account Settings page. + """ + SUCCESS_MESSAGE = 'Your changes have been saved.' + + def setUp(self): + """ + Initialize account and pages. + """ + super(AccountSettingsPageTest, self).setUp() + + # Visit the account settings page for the current user. + self.account_settings_page = AccountSettingsPage(self.browser) + self.account_settings_page.visit() + + def test_page_view_event(self): + """ + Scenario: An event should be recorded when the "Account Settings" + page is viewed. + + Given that I am a registered user + And I visit my account settings page + Then a page view analytics event should be recorded + """ + self.verify_events_of_type( + u"edx.user.settings.viewed", + [{ + u"user_id": int(self.user_id), + u"page": u"account", + u"visibility": None, + }] + ) + + def test_all_sections_and_fields_are_present(self): + """ + Scenario: Verify that all sections and fields are present on the page. + """ + expected_sections_structure = [ + { + 'title': 'Basic Account Information (required)', + 'fields': [ + 'Username', + 'Full Name', + 'Email Address', + 'Password', + 'Language', + 'Country or Region' + ] + }, + { + 'title': 'Additional Information (optional)', + 'fields': [ + 'Education Completed', + 'Gender', + 'Year of Birth', + 'Preferred Language', + ] + }, + { + 'title': 'Connected Accounts', + 'fields': [ + 'Facebook', + 'Google', + ] + } + ] + + self.assertEqual(self.account_settings_page.sections_structure(), expected_sections_structure) + + def _test_readonly_field(self, field_id, title, value): + """ + Test behavior of a readonly field. + """ + self.assertEqual(self.account_settings_page.title_for_field(field_id), title) + self.assertEqual(self.account_settings_page.value_for_readonly_field(field_id), value) + + def _test_text_field( + self, field_id, title, initial_value, new_invalid_value, new_valid_values, success_message=SUCCESS_MESSAGE, + assert_after_reload=True + ): + """ + Test behaviour of a text field. + """ + self.assertEqual(self.account_settings_page.title_for_field(field_id), title) + self.assertEqual(self.account_settings_page.value_for_text_field(field_id), initial_value) + + self.assertEqual( + self.account_settings_page.value_for_text_field(field_id, new_invalid_value), new_invalid_value + ) + self.account_settings_page.wait_for_indicator(field_id, 'validation-error') + self.browser.refresh() + self.assertNotEqual(self.account_settings_page.value_for_text_field(field_id), new_invalid_value) + + for new_value in new_valid_values: + self.assertEqual(self.account_settings_page.value_for_text_field(field_id, new_value), new_value) + self.account_settings_page.wait_for_messsage(field_id, success_message) + if assert_after_reload: + self.browser.refresh() + self.assertEqual(self.account_settings_page.value_for_text_field(field_id), new_value) + + def _test_dropdown_field( + self, field_id, title, initial_value, new_values, success_message=SUCCESS_MESSAGE, reloads_on_save=False + ): + """ + Test behaviour of a dropdown field. + """ + self.assertEqual(self.account_settings_page.title_for_field(field_id), title) + self.assertEqual(self.account_settings_page.value_for_dropdown_field(field_id), initial_value) + + for new_value in new_values: + self.assertEqual(self.account_settings_page.value_for_dropdown_field(field_id, new_value), new_value) + self.account_settings_page.wait_for_messsage(field_id, success_message) + if reloads_on_save: + self.account_settings_page.wait_for_loading_indicator() + else: + self.browser.refresh() + self.assertEqual(self.account_settings_page.value_for_dropdown_field(field_id), new_value) + + def _test_link_field(self, field_id, title, link_title, success_message): + """ + Test behaviour a link field. + """ + self.assertEqual(self.account_settings_page.title_for_field(field_id), title) + self.assertEqual(self.account_settings_page.link_title_for_link_field(field_id), link_title) + self.account_settings_page.click_on_link_in_link_field(field_id) + self.account_settings_page.wait_for_messsage(field_id, success_message) + + def test_username_field(self): + """ + Test behaviour of "Username" field. + """ + self._test_readonly_field( + 'username', + 'Username', + self.USERNAME, + ) + + def test_full_name_field(self): + """ + Test behaviour of "Full Name" field. + """ + self._test_text_field( + u'name', + u'Full Name', + self.USERNAME, + u'@', + [u'another name', self.USERNAME], + ) + + def test_email_field(self): + """ + Test behaviour of "Email" field. + """ + self._test_text_field( + u'email', + u'Email Address', + self.EMAIL, + u'@', + [u'me@here.com', u'you@there.com'], + success_message='Click the link in the message to update your email address.', + assert_after_reload=False + ) + + self.verify_events_of_type( + self.CHANGE_INITIATED_EVENT_NAME, + [ + { + u"user_id": int(self.user_id), + u"setting": u"email", + u"old": self.EMAIL, + u"new": u'me@here.com' + }, + { + u"user_id": int(self.user_id), + u"setting": u"email", + u"old": self.EMAIL, # NOTE the first email change was never confirmed, so old has not changed. + u"new": u'you@there.com' + } + ], + [self.ACCOUNT_SETTINGS_REFERER, self.ACCOUNT_SETTINGS_REFERER] + ) + + def test_password_field(self): + """ + Test behaviour of "Password" field. + """ + self._test_link_field( + u'password', + u'Password', + u'Reset Password', + success_message='Click the link in the message to reset your password.', + ) + + self.verify_events_of_type( + self.CHANGE_INITIATED_EVENT_NAME, + [{ + u"user_id": int(self.user_id), + u"setting": "password", + u"old": None, + u"new": None + }], + [self.ACCOUNT_SETTINGS_REFERER] + ) + + @skip( + 'On bokchoy test servers, language changes take a few reloads to fully realize ' + 'which means we can no longer reliably match the strings in the html in other tests.' + ) + def test_language_field(self): + """ + Test behaviour of "Language" field. + """ + self._test_dropdown_field( + u'pref-lang', + u'Language', + u'English', + [u'Dummy Language (Esperanto)', u'English'], + reloads_on_save=True, + ) + + def test_education_completed_field(self): + """ + Test behaviour of "Education Completed" field. + """ + self._test_dropdown_field( + u'level_of_education', + u'Education Completed', + u'', + [u'Bachelor\'s degree', u''], + ) + + def test_gender_field(self): + """ + Test behaviour of "Gender" field. + """ + self._test_dropdown_field( + u'gender', + u'Gender', + u'', + [u'Female', u''], + ) + + def test_year_of_birth_field(self): + """ + Test behaviour of "Year of Birth" field. + """ + self.assertEqual(self.account_settings_page.value_for_dropdown_field('year_of_birth', ''), '') + self._test_dropdown_field( + u'year_of_birth', + u'Year of Birth', + u'', + [u'1980', u''], + ) + + def test_country_field(self): + """ + Test behaviour of "Country or Region" field. + """ + self._test_dropdown_field( + u'country', + u'Country or Region', + u'', + [u'Pakistan', u'Palau'], + ) + + def test_preferred_language_field(self): + """ + Test behaviour of "Preferred Language" field. + """ + self._test_dropdown_field( + u'language_proficiencies', + u'Preferred Language', + u'', + [u'Pushto', u''], + ) + + def test_connected_accounts(self): + """ + Test that fields for third party auth providers exist. + + Currently there is no way to test the whole authentication process + because that would require accounts with the providers. + """ + for field_id, title, link_title in [ + ['auth-facebook', 'Facebook', 'Link'], + ['auth-google', 'Google', 'Link'], + ]: + self.assertEqual(self.account_settings_page.title_for_field(field_id), title) + self.assertEqual(self.account_settings_page.link_title_for_link_field(field_id), link_title) diff --git a/common/test/acceptance/tests/lms/test_learner_profile.py b/common/test/acceptance/tests/lms/test_learner_profile.py new file mode 100644 index 000000000000..7bcad795865d --- /dev/null +++ b/common/test/acceptance/tests/lms/test_learner_profile.py @@ -0,0 +1,589 @@ +# -*- coding: utf-8 -*- +""" +End-to-end tests for Student's Profile Page. +""" +from datetime import datetime +from bok_choy.web_app_test import WebAppTest + +from ...pages.common.logout import LogoutPage +from ...pages.lms.account_settings import AccountSettingsPage +from ...pages.lms.auto_auth import AutoAuthPage +from ...pages.lms.learner_profile import LearnerProfilePage +from ...pages.lms.dashboard import DashboardPage + +from ..helpers import EventsTestMixin + + +class LearnerProfileTestMixin(EventsTestMixin): + """ + Mixin with helper methods for testing learner profile pages. + """ + + PRIVACY_PUBLIC = 'all_users' + PRIVACY_PRIVATE = 'private' + + PUBLIC_PROFILE_FIELDS = ['username', 'country', 'language_proficiencies', 'bio'] + PRIVATE_PROFILE_FIELDS = ['username'] + + PUBLIC_PROFILE_EDITABLE_FIELDS = ['country', 'language_proficiencies', 'bio'] + + def log_in_as_unique_user(self): + """ + Create a unique user and return the account's username and id. + """ + username = "test_{uuid}".format(uuid=self.unique_id[0:6]) + auto_auth_page = AutoAuthPage(self.browser, username=username).visit() + user_id = auto_auth_page.get_user_id() + return username, user_id + + def set_public_profile_fields_data(self, profile_page): + """ + Fill in the public profile fields of a user. + """ + profile_page.value_for_dropdown_field('language_proficiencies', 'English') + profile_page.value_for_dropdown_field('country', 'United Kingdom') + profile_page.value_for_textarea_field('bio', 'Nothing Special') + + def visit_profile_page(self, username, privacy=None): + """ + Visits a user's profile page. + """ + profile_page = LearnerProfilePage(self.browser, username) + + # Change the privacy if requested by loading the page and + # changing the drop down + if privacy is not None: + profile_page.visit() + profile_page.wait_for_page() + profile_page.privacy = privacy + + if privacy == self.PRIVACY_PUBLIC: + self.set_public_profile_fields_data(profile_page) + + # Reset event tracking so that the tests only see events from + # loading the profile page. + self.reset_event_tracking() + + # Load the page + profile_page.visit() + profile_page.wait_for_page() + + return profile_page + + def set_birth_year(self, birth_year): + """ + Set birth year for the current user to the specified value. + """ + account_settings_page = AccountSettingsPage(self.browser) + account_settings_page.visit() + account_settings_page.wait_for_page() + self.assertEqual( + account_settings_page.value_for_dropdown_field('year_of_birth', str(birth_year)), + str(birth_year) + ) + + def verify_profile_page_view_event(self, profile_user_id, visibility=None): + """ + Verifies that the correct view event was captured for the profile page. + """ + self.verify_events_of_type( + u"edx.user.settings.viewed", + [{ + u"user_id": int(profile_user_id), + u"page": u"profile", + u"visibility": unicode(visibility), + }] + ) + + +class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest): + """ + Tests that verify a student's own profile page. + """ + + def verify_profile_forced_private_message(self, username, birth_year, message=None): + """ + Verify age limit messages for a user. + """ + self.set_birth_year(birth_year=birth_year if birth_year is not None else "") + profile_page = self.visit_profile_page(username) + self.assertTrue(profile_page.privacy_field_visible) + self.assertEqual(profile_page.age_limit_message_present, message is not None) + self.assertIn(message, profile_page.profile_forced_private_message) + + def test_profile_defaults_to_public(self): + """ + Scenario: Verify that a new user's profile defaults to public. + + Given that I am a new user. + When I go to my profile page. + Then I see that the profile visibility is set to public. + """ + username, user_id = self.log_in_as_unique_user() + profile_page = self.visit_profile_page(username) + self.assertTrue(profile_page.privacy_field_visible) + self.assertEquals(profile_page.privacy, self.PRIVACY_PUBLIC) + + 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. + + Given that I am a registered user. + When I go to Dashboard page. + And I click on username dropdown. + Then I see My Profile link in the dropdown menu. + When I click on My Profile link. + Then I will be navigated to My Profile page. + """ + username, user_id = self.log_in_as_unique_user() + dashboard_page = DashboardPage(self.browser) + dashboard_page.visit() + dashboard_page.click_username_dropdown() + self.assertTrue('My Profile' in dashboard_page.username_dropdown_link_text) + dashboard_page.click_my_profile_link() + my_profile_page = LearnerProfilePage(self.browser, username) + my_profile_page.wait_for_page() + + def test_fields_on_my_private_profile(self): + """ + Scenario: Verify that desired fields are shown when looking at her own private profile. + + Given that I am a registered user. + And I visit My Profile page. + And I set the profile visibility to private. + And I reload the page. + Then I should see the profile visibility selector dropdown. + Then I see some of the profile fields are shown. + """ + username, user_id = self.log_in_as_unique_user() + profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PRIVATE) + + self.assertTrue(profile_page.privacy_field_visible) + self.assertEqual(profile_page.visible_fields, self.PRIVATE_PROFILE_FIELDS) + + self.verify_profile_page_view_event(user_id, visibility=self.PRIVACY_PRIVATE) + + def test_fields_on_my_public_profile(self): + """ + Scenario: Verify that desired fields are shown when looking at her own public profile. + + Given that I am a registered user. + And I visit My Profile page. + And I set the profile visibility to public. + And I reload the page. + Then I should see the profile visibility selector dropdown. + Then I see all the profile fields are shown. + And `location`, `language` and `about me` fields are editable. + """ + username, user_id = self.log_in_as_unique_user() + profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC) + + self.assertTrue(profile_page.privacy_field_visible) + self.assertEqual(profile_page.visible_fields, self.PUBLIC_PROFILE_FIELDS) + + self.assertEqual(profile_page.editable_fields, self.PUBLIC_PROFILE_EDITABLE_FIELDS) + + self.verify_profile_page_view_event(user_id, visibility=self.PRIVACY_PUBLIC) + + def _test_dropdown_field(self, profile_page, field_id, new_value, displayed_value, mode): + """ + Test behaviour of a dropdown field. + """ + profile_page.value_for_dropdown_field(field_id, new_value) + self.assertEqual(profile_page.get_non_editable_mode_value(field_id), displayed_value) + self.assertTrue(profile_page.mode_for_field(field_id), mode) + + self.browser.refresh() + profile_page.wait_for_page() + + self.assertEqual(profile_page.get_non_editable_mode_value(field_id), displayed_value) + self.assertTrue(profile_page.mode_for_field(field_id), mode) + + def _test_textarea_field(self, profile_page, field_id, new_value, displayed_value, mode): + """ + Test behaviour of a textarea field. + """ + profile_page.value_for_textarea_field(field_id, new_value) + self.assertEqual(profile_page.get_non_editable_mode_value(field_id), displayed_value) + self.assertTrue(profile_page.mode_for_field(field_id), mode) + + self.browser.refresh() + profile_page.wait_for_page() + + self.assertEqual(profile_page.get_non_editable_mode_value(field_id), displayed_value) + self.assertTrue(profile_page.mode_for_field(field_id), mode) + + def test_country_field(self): + """ + Test behaviour of `Country` field. + + Given that I am a registered user. + And I visit My Profile page. + And I set the profile visibility to public and set default values for public fields. + Then I set country value to `Pakistan`. + Then displayed country should be `Pakistan` and country field mode should be `display` + And I reload the page. + Then displayed country should be `Pakistan` and country field mode should be `display` + And I make `country` field editable + Then `country` field mode should be `edit` + And `country` field icon should be visible. + """ + username, user_id = self.log_in_as_unique_user() + profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC) + self._test_dropdown_field(profile_page, 'country', 'Pakistan', 'Pakistan', 'display') + + profile_page.make_field_editable('country') + self.assertTrue(profile_page.mode_for_field('country'), 'edit') + + self.assertTrue(profile_page.field_icon_present('country')) + + def test_language_field(self): + """ + Test behaviour of `Language` field. + + Given that I am a registered user. + And I visit My Profile page. + And I set the profile visibility to public and set default values for public fields. + Then I set language value to `Urdu`. + Then displayed language should be `Urdu` and language field mode should be `display` + And I reload the page. + Then displayed language should be `Urdu` and language field mode should be `display` + Then I set empty value for language. + Then displayed language should be `Add language` and language field mode should be `placeholder` + And I reload the page. + Then displayed language should be `Add language` and language field mode should be `placeholder` + And I make `language` field editable + Then `language` field mode should be `edit` + And `language` field icon should be visible. + """ + username, user_id = self.log_in_as_unique_user() + profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC) + self._test_dropdown_field(profile_page, 'language_proficiencies', 'Urdu', 'Urdu', 'display') + self._test_dropdown_field(profile_page, 'language_proficiencies', '', 'Add language', 'placeholder') + + profile_page.make_field_editable('language_proficiencies') + self.assertTrue(profile_page.mode_for_field('language_proficiencies'), 'edit') + + self.assertTrue(profile_page.field_icon_present('language_proficiencies')) + + def test_about_me_field(self): + """ + Test behaviour of `About Me` field. + + Given that I am a registered user. + And I visit My Profile page. + And I set the profile visibility to public and set default values for public fields. + Then I set about me value to `Eat Sleep Code`. + Then displayed about me should be `Eat Sleep Code` and about me field mode should be `display` + And I reload the page. + Then displayed about me should be `Eat Sleep Code` and about me field mode should be `display` + Then I set empty value for about me. + Then displayed about me should be `Tell other edX learners a little about yourself: where you live, + what your interests are, why you're taking courses on edX, or what you hope to learn.` and about me + field mode should be `placeholder` + And I reload the page. + Then displayed about me should be `Tell other edX learners a little about yourself: where you live, + what your interests are, why you're taking courses on edX, or what you hope to learn.` and about me + field mode should be `placeholder` + And I make `about me` field editable + Then `about me` field mode should be `edit` + """ + placeholder_value = ( + "Tell other edX learners a little about yourself: where you live, what your interests are, " + "why you're taking courses on edX, or what you hope to learn." + ) + + username, user_id = self.log_in_as_unique_user() + profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC) + self._test_textarea_field(profile_page, 'bio', 'Eat Sleep Code', 'Eat Sleep Code', 'display') + self._test_textarea_field(profile_page, 'bio', '', placeholder_value, 'placeholder') + + profile_page.make_field_editable('bio') + self.assertTrue(profile_page.mode_for_field('bio'), 'edit') + + def test_birth_year_not_set(self): + """ + Verify message if birth year is not set. + + Given that I am a registered user. + And birth year is not set for the user. + And I visit my profile page. + Then I should see a message that the profile is private until the year of birth is set. + """ + username, user_id = self.log_in_as_unique_user() + message = "You must specify your birth year before you can share your full profile." + self.verify_profile_forced_private_message(username, birth_year=None, message=message) + self.verify_profile_page_view_event(user_id, visibility=self.PRIVACY_PRIVATE) + + def test_user_is_under_age(self): + """ + Verify message if user is under age. + + Given that I am a registered user. + And birth year is set so that age is less than 13. + And I visit my profile page. + Then I should see a message that the profile is private as I am under thirteen. + """ + username, user_id = self.log_in_as_unique_user() + under_age_birth_year = datetime.now().year - 10 + self.verify_profile_forced_private_message( + username, + birth_year=under_age_birth_year, + message='You must be over 13 to share a full profile.' + ) + 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): + """ + Tests that verify viewing the profile page of a different user. + """ + def test_different_user_private_profile(self): + """ + Scenario: Verify that desired fields are shown when looking at a different user's private profile. + + Given that I am a registered user. + And I visit a different user's private profile page. + Then I shouldn't see the profile visibility selector dropdown. + Then I see some of the profile fields are shown. + """ + different_username, different_user_id = self._initialize_different_user(privacy=self.PRIVACY_PRIVATE) + self.log_in_as_unique_user() + profile_page = self.visit_profile_page(different_username) + + self.assertFalse(profile_page.privacy_field_visible) + self.assertEqual(profile_page.visible_fields, self.PRIVATE_PROFILE_FIELDS) + self.verify_profile_page_view_event(different_user_id, visibility=self.PRIVACY_PRIVATE) + + def test_different_user_under_age(self): + """ + Scenario: Verify that desired fields are shown when looking at a different user's private profile. + + Given that I am a registered user. + And I visit an under age user's profile page. + Then I shouldn't see the profile visibility selector dropdown. + Then I see some of the profile fields are shown. + """ + under_age_birth_year = datetime.now().year - 10 + different_username, different_user_id = self._initialize_different_user( + privacy=self.PRIVACY_PUBLIC, + birth_year=under_age_birth_year + ) + self.log_in_as_unique_user() + profile_page = self.visit_profile_page(different_username) + + self.assertFalse(profile_page.privacy_field_visible) + self.assertEqual(profile_page.visible_fields, self.PRIVATE_PROFILE_FIELDS) + self.verify_profile_page_view_event(different_user_id, visibility=self.PRIVACY_PRIVATE) + + def test_different_user_public_profile(self): + """ + Scenario: Verify that desired fields are shown when looking at a different user's public profile. + + Given that I am a registered user. + And I visit a different user's public profile page. + Then I shouldn't see the profile visibility selector dropdown. + Then all the profile fields are shown. + Then I shouldn't see the profile visibility selector dropdown. + Also `location`, `language` and `about me` fields are not editable. + """ + different_username, different_user_id = self._initialize_different_user(privacy=self.PRIVACY_PUBLIC) + self.log_in_as_unique_user() + profile_page = self.visit_profile_page(different_username) + profile_page.wait_for_public_fields() + self.assertFalse(profile_page.privacy_field_visible) + self.assertEqual(profile_page.visible_fields, self.PUBLIC_PROFILE_FIELDS) + self.assertEqual(profile_page.editable_fields, []) + self.verify_profile_page_view_event(different_user_id, visibility=self.PRIVACY_PUBLIC) + + def _initialize_different_user(self, privacy=None, birth_year=None): + """ + Initialize the profile page for a different test user + """ + username, user_id = self.log_in_as_unique_user() + + # Set the privacy for the new user + if privacy is None: + privacy = self.PRIVACY_PUBLIC + self.visit_profile_page(username, privacy=privacy) + + # Set the user's year of birth + if birth_year: + self.set_birth_year(birth_year) + + # Log the user out + LogoutPage(self.browser).visit() + + return username, user_id diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py index 488a25519575..3537a15e7505 100644 --- a/common/test/acceptance/tests/lms/test_lms.py +++ b/common/test/acceptance/tests/lms/test_lms.py @@ -173,9 +173,11 @@ def test_register_success(self): course_names = self.dashboard_page.wait_for_page().available_courses self.assertIn(self.course_info["display_name"], course_names) - self.assertEqual("Test User", self.dashboard_page.full_name) - self.assertEqual(email, self.dashboard_page.email) - self.assertEqual(username, self.dashboard_page.username) + self.assertEqual("want to change your account settings?", self.dashboard_page.sidebar_menu_title.lower()) + self.assertEqual( + "click the arrow next to your username above.", + self.dashboard_page.sidebar_menu_description.lower() + ) def test_register_failure(self): # Navigate to the registration page @@ -369,61 +371,6 @@ def test_enrollment_upgrade(self): self.assertEqual(enrollment_mode, 'verified') -class LanguageTest(WebAppTest): - """ - Tests that the change language functionality on the dashboard works - """ - - def setUp(self): - """ - Initiailize dashboard page - """ - super(LanguageTest, self).setUp() - self.dashboard_page = DashboardPage(self.browser) - - self.test_new_lang = 'eo' - # This string is unicode for "ÇÜRRÉNT ÇØÜRSÉS", which should appear in our Dummy Esperanto page - # We store the string this way because Selenium seems to try and read in strings from - # the HTML in this format. Ideally we could just store the raw ÇÜRRÉNT ÇØÜRSÉS string here - self.current_courses_text = u'\xc7\xdcRR\xc9NT \xc7\xd6\xdcRS\xc9S' - - self.username = "test" - self.password = "testpass" - self.email = "test@example.com" - - def test_change_lang(self): - AutoAuthPage(self.browser).visit() - self.dashboard_page.visit() - # Change language to Dummy Esperanto - self.dashboard_page.change_language(self.test_new_lang) - - changed_text = self.dashboard_page.current_courses_text - - # We should see the dummy-language text on the page - self.assertIn(self.current_courses_text, changed_text) - - def test_language_persists(self): - auto_auth_page = AutoAuthPage(self.browser, username=self.username, password=self.password, email=self.email) - auto_auth_page.visit() - - self.dashboard_page.visit() - # Change language to Dummy Esperanto - self.dashboard_page.change_language(self.test_new_lang) - - # destroy session - self.browser.delete_all_cookies() - - # log back in - auto_auth_page.visit() - - self.dashboard_page.visit() - - changed_text = self.dashboard_page.current_courses_text - - # We should see the dummy-language text on the page - self.assertIn(self.current_courses_text, changed_text) - - class CourseWikiTest(UniqueCourseTest): """ Tests that verify the course wiki. diff --git a/common/test/data/uploads/larger_image.jpg b/common/test/data/uploads/larger_image.jpg new file mode 100644 index 000000000000..18a3e1c6f987 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 000000000000..a3704f3b9829 Binary files /dev/null and b/common/test/data/uploads/list-icon-visited.png differ diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index 57f61e1de0a7..1e66a97c9f41 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -9,6 +9,7 @@ from django.contrib.auth.decorators import login_required from django.core.context_processors import csrf +from django.core.urlresolvers import reverse from django.contrib.auth.models import User from django.http import Http404, HttpResponseBadRequest from django.views.decorators.http import require_GET @@ -411,16 +412,18 @@ def user_profile(request, course_key, user_id): 'annotated_content_info': _attr_safe_json(annotated_content_info), }) else: + django_user = User.objects.get(id=user_id) context = { 'course': course, 'user': request.user, - 'django_user': User.objects.get(id=user_id), + 'django_user': django_user, 'profiled_user': profiled_user.to_dict(), 'threads': _attr_safe_json(threads), 'user_info': _attr_safe_json(user_info), 'annotated_content_info': _attr_safe_json(annotated_content_info), 'page': query_params['page'], 'num_pages': query_params['num_pages'], + 'learner_profile_page_url': reverse('learner_profile', kwargs={'username': django_user.username}) } return render_to_response('discussion/user_profile.html', context) diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index c2c99faf9807..880a622b7466 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -9,20 +9,24 @@ import mock import ddt import markupsafe -from django.test import TestCase from django.conf import settings from django.core.urlresolvers import reverse from django.core import mail +from django.contrib import messages +from django.contrib.messages.middleware import MessageMiddleware +from django.test import TestCase from django.test.utils import override_settings +from django.test.client import RequestFactory -from util.testing import UrlResetMixin -from third_party_auth.tests.testutil import simulate_running_pipeline from embargo.test_utils import restrict_course from openedx.core.djangoapps.user_api.accounts.api import activate_account, create_account from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH +from student.tests.factories import CourseModeFactory, UserFactory +from student_account.views import account_settings_context +from third_party_auth.tests.testutil import simulate_running_pipeline +from util.testing import UrlResetMixin from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from student.tests.factories import CourseModeFactory @ddt.ddt @@ -499,3 +503,66 @@ def _third_party_login_url(self, backend_name, auth_entry, course_id=None, redir url=reverse("social:begin", kwargs={"backend": backend_name}), params=urlencode(params) ) + + +class AccountSettingsViewTest(TestCase): + """ Tests for the account settings view. """ + + USERNAME = 'student' + PASSWORD = 'password' + FIELDS = [ + 'country', + 'gender', + 'language', + 'level_of_education', + 'password', + 'year_of_birth', + 'preferred_language', + ] + + @mock.patch("django.conf.settings.MESSAGE_STORAGE", 'django.contrib.messages.storage.cookie.CookieStorage') + def setUp(self): + super(AccountSettingsViewTest, self).setUp() + self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD) + self.client.login(username=self.USERNAME, password=self.PASSWORD) + + self.request = RequestFactory() + self.request.user = self.user + + # Python-social saves auth failure notifcations in Django messages. + # See pipeline.get_duplicate_provider() for details. + self.request.COOKIES = {} + MessageMiddleware().process_request(self.request) + messages.error(self.request, 'Facebook is already in use.', extra_tags='Auth facebook') + + def test_context(self): + + context = account_settings_context(self.request) + + user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username}) + self.assertEqual(context['user_accounts_api_url'], user_accounts_api_url) + + user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username}) + self.assertEqual(context['user_preferences_api_url'], user_preferences_api_url) + + for attribute in self.FIELDS: + self.assertIn(attribute, context['fields']) + + self.assertEqual( + context['user_accounts_api_url'], reverse("accounts_api", kwargs={'username': self.user.username}) + ) + self.assertEqual( + context['user_preferences_api_url'], reverse('preferences_api', kwargs={'username': self.user.username}) + ) + + self.assertEqual(context['duplicate_provider'].BACKEND_CLASS.name, 'facebook') + self.assertEqual(context['auth']['providers'][0]['name'], 'Facebook') + self.assertEqual(context['auth']['providers'][1]['name'], 'Google') + + def test_view(self): + + view_path = reverse('account_settings') + response = self.client.get(path=view_path) + + for attribute in self.FIELDS: + self.assertIn(attribute, response.content) diff --git a/lms/djangoapps/student_account/urls.py b/lms/djangoapps/student_account/urls.py index 4f5eba7c0448..a172f25ba4ff 100644 --- a/lms/djangoapps/student_account/urls.py +++ b/lms/djangoapps/student_account/urls.py @@ -11,3 +11,8 @@ url(r'^register/$', 'login_and_registration_form', {'initial_mode': 'register'}, name='account_register'), url(r'^password$', 'password_change_request_handler', name='password_change_request'), ) + +urlpatterns += patterns( + 'student_account.views', + url(r'^settings$', 'account_settings', name='account_settings'), +) diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index 47f2a56c479a..1838ff2e88c8 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -5,30 +5,39 @@ from ipware.ip import get_ip from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required from django.http import ( HttpResponse, HttpResponseBadRequest, HttpResponseForbidden ) from django.shortcuts import redirect from django.http import HttpRequest +from django_countries import countries from django.core.urlresolvers import reverse, resolve from django.utils.translation import ugettext as _ from django_future.csrf import ensure_csrf_cookie from django.views.decorators.http import require_http_methods -from opaque_keys.edx.keys import CourseKey +from lang_pref.api import released_languages from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey from edxmako.shortcuts import render_to_response from microsite_configuration import microsite + from embargo import api as embargo_api -import third_party_auth from external_auth.login_and_register import ( login as external_auth_login, register as external_auth_register ) +from student.models import UserProfile from student.views import ( signin_user as old_login_view, register_user as old_register_view ) +from student_account.helpers import auth_pipeline_urls +import third_party_auth +from third_party_auth import pipeline +from util.bad_request_rate_limiter import BadRequestRateLimiter from openedx.core.djangoapps.user_api.accounts.api import request_password_change from openedx.core.djangoapps.user_api.errors import UserNotFound @@ -294,3 +303,96 @@ def _external_auth_intercept(request, mode): return external_auth_login(request) elif mode == "register": return external_auth_register(request) + + +@login_required +@require_http_methods(['GET']) +def account_settings(request): + """Render the current user's account settings page. + + Args: + request (HttpRequest) + + Returns: + HttpResponse: 200 if the page was sent successfully + HttpResponse: 302 if not logged in (redirect to login page) + HttpResponse: 405 if using an unsupported HTTP method + + Example usage: + + GET /account/settings + + """ + return render_to_response('student_account/account_settings.html', account_settings_context(request)) + + +def account_settings_context(request): + """ Context for the account settings page. + + Args: + request: The request object. + + Returns: + dict + + """ + user = request.user + + country_options = [ + (country_code, _(country_name)) # pylint: disable=translation-of-non-string + for country_code, country_name in sorted( + countries.countries, key=lambda(__, name): unicode(name) + ) + ] + + year_of_birth_options = [(unicode(year), unicode(year)) for year in UserProfile.VALID_YEARS] + + context = { + 'auth': {}, + 'duplicate_provider': None, + 'fields': { + 'country': { + 'options': country_options, + }, 'gender': { + 'options': [(choice[0], _(choice[1])) for choice in UserProfile.GENDER_CHOICES], # pylint: disable=translation-of-non-string + }, 'language': { + 'options': released_languages(), + }, 'level_of_education': { + 'options': [(choice[0], _(choice[1])) for choice in UserProfile.LEVEL_OF_EDUCATION_CHOICES], # pylint: disable=translation-of-non-string + }, 'password': { + 'url': reverse('password_reset'), + }, 'year_of_birth': { + 'options': year_of_birth_options, + }, 'preferred_language': { + 'options': settings.ALL_LANGUAGES, + } + }, + 'platform_name': settings.PLATFORM_NAME, + 'user_accounts_api_url': reverse("accounts_api", kwargs={'username': user.username}), + 'user_preferences_api_url': reverse('preferences_api', kwargs={'username': user.username}), + } + + if third_party_auth.is_enabled(): + # If the account on the third party provider is already connected with another edX account, + # we display a message to the user. + context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request)) + + auth_states = pipeline.get_provider_user_states(user) + + context['auth']['providers'] = [{ + 'name': state.provider.NAME, # The name of the provider e.g. Facebook + 'connected': state.has_account, # Whether the user's edX account is connected with the provider. + # If the user is not connected, they should be directed to this page to authenticate + # with the particular provider. + 'connect_url': pipeline.get_login_url( + state.provider.NAME, + pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS, + # The url the user should be directed to after the auth process has completed. + redirect_url=reverse('account_settings'), + ), + # If the user is connected, sending a POST request to this url removes the connection + # information for this provider from their edX account. + 'disconnect_url': pipeline.get_disconnect_url(state.provider.NAME), + } for state in auth_states] + + return context diff --git a/lms/djangoapps/student_profile/__init__.py b/lms/djangoapps/student_profile/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/student_profile/test/__init__.py b/lms/djangoapps/student_profile/test/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/student_profile/test/test_views.py b/lms/djangoapps/student_profile/test/test_views.py new file mode 100644 index 000000000000..aa7818310887 --- /dev/null +++ b/lms/djangoapps/student_profile/test/test_views.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +""" Tests for student profile views. """ + +from django.conf import settings +from django.core.urlresolvers import reverse +from django.test import TestCase + +from util.testing import UrlResetMixin +from student.tests.factories import UserFactory + +from student_profile.views import learner_profile_context + + +class LearnerProfileViewTest(UrlResetMixin, TestCase): + """ Tests for the student profile view. """ + + USERNAME = "username" + PASSWORD = "password" + CONTEXT_DATA = [ + 'default_public_account_fields', + 'accounts_api_url', + 'preferences_api_url', + 'account_settings_page_url', + 'has_preferences_access', + 'own_profile', + 'country_options', + 'language_options', + ] + + def setUp(self): + super(LearnerProfileViewTest, self).setUp() + self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD) + self.client.login(username=self.USERNAME, password=self.PASSWORD) + + def test_context(self): + """ + Verify learner profile page context data. + """ + context = learner_profile_context(self.user.username, self.USERNAME, self.user.is_staff) + + self.assertEqual( + context['data']['default_public_account_fields'], + settings.ACCOUNT_VISIBILITY_CONFIGURATION['public_fields'] + ) + + self.assertEqual( + context['data']['accounts_api_url'], + reverse("accounts_api", kwargs={'username': self.user.username}) + ) + + self.assertEqual( + context['data']['preferences_api_url'], + reverse('preferences_api', kwargs={'username': self.user.username}) + ) + + self.assertEqual( + context['data']['profile_image_upload_url'], + reverse("profile_image_upload", kwargs={'username': self.user.username}) + ) + + self.assertEqual( + context['data']['profile_image_remove_url'], + reverse('profile_image_remove', kwargs={'username': self.user.username}) + ) + + self.assertEqual( + context['data']['profile_image_max_bytes'], + settings.PROFILE_IMAGE_MAX_BYTES + ) + + self.assertEqual( + context['data']['profile_image_min_bytes'], + settings.PROFILE_IMAGE_MIN_BYTES + ) + + self.assertEqual(context['data']['account_settings_page_url'], reverse('account_settings')) + + for attribute in self.CONTEXT_DATA: + self.assertIn(attribute, context['data']) + + def test_view(self): + """ + Verify learner profile page view. + """ + profile_path = reverse('learner_profile', kwargs={'username': self.USERNAME}) + response = self.client.get(path=profile_path) + + for attribute in self.CONTEXT_DATA: + self.assertIn(attribute, response.content) + + def test_undefined_profile_page(self): + """ + Verify that a 404 is returned for a non-existent profile page. + """ + profile_path = reverse('learner_profile', kwargs={'username': "no_such_user"}) + response = self.client.get(path=profile_path) + self.assertEqual(404, response.status_code) diff --git a/lms/djangoapps/student_profile/views.py b/lms/djangoapps/student_profile/views.py new file mode 100644 index 000000000000..7ac0b0949121 --- /dev/null +++ b/lms/djangoapps/student_profile/views.py @@ -0,0 +1,87 @@ +""" Views for a student's profile information. """ + +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist +from django_countries import countries + +from django.core.urlresolvers import reverse +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse +from django.views.decorators.http import require_http_methods + +from edxmako.shortcuts import render_to_response +from student.models import User + +from django.utils.translation import ugettext as _ + + +@login_required +@require_http_methods(['GET']) +def learner_profile(request, username): + """Render the profile page for the specified username. + + Args: + request (HttpRequest) + username (str): username of user whose profile is requested. + + Returns: + HttpResponse: 200 if the page was sent successfully + HttpResponse: 302 if not logged in (redirect to login page) + HttpResponse: 404 if the specified username does not exist + HttpResponse: 405 if using an unsupported HTTP method + + Example usage: + GET /account/profile + """ + try: + return render_to_response( + 'student_profile/learner_profile.html', + learner_profile_context(request.user.username, username, request.user.is_staff) + ) + except ObjectDoesNotExist: + return HttpResponse(status=404) + + +def learner_profile_context(logged_in_username, profile_username, user_is_staff): + """Context for the learner profile page. + + Args: + logged_in_username (str): Username of user logged In user. + profile_username (str): username of user whose profile is requested. + user_is_staff (bool): Logged In user has staff access. + + Returns: + dict + + Raises: + ObjectDoesNotExist: the specified profile_username does not exist. + """ + profile_user = User.objects.get(username=profile_username) + + country_options = [ + (country_code, _(country_name)) # pylint: disable=translation-of-non-string + for country_code, country_name in sorted( + countries.countries, key=lambda(__, name): unicode(name) + ) + ] + + context = { + 'data': { + 'profile_user_id': profile_user.id, + 'default_public_account_fields': settings.ACCOUNT_VISIBILITY_CONFIGURATION['public_fields'], + 'default_visibility': settings.ACCOUNT_VISIBILITY_CONFIGURATION['default_visibility'], + '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), + 'country_options': country_options, + 'language_options': settings.ALL_LANGUAGES, + } + } + + return context diff --git a/lms/envs/bok_choy.auth.json b/lms/envs/bok_choy.auth.json index 482081219286..cd47547ed2f1 100644 --- a/lms/envs/bok_choy.auth.json +++ b/lms/envs/bok_choy.auth.json @@ -49,6 +49,15 @@ ], "port": 27017 }, + "TRACKING_BACKENDS": { + "mongo": { + "ENGINE": "track.backends.mongodb.MongoBackend", + "OPTIONS": { + "database": "test", + "collection": "events" + } + } + }, "EVENT_TRACKING_BACKENDS": { "mongo": { "ENGINE": "eventtracking.backends.mongodb.MongoBackend", diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py index 8cfd1e48d98e..0070b1513f8f 100644 --- a/lms/envs/bok_choy.py +++ b/lms/envs/bok_choy.py @@ -131,6 +131,14 @@ 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/dashboard/legacy.js b/lms/static/js/dashboard/legacy.js index 9abdad1a3186..fa0c5eedf10e 100644 --- a/lms/static/js/dashboard/legacy.js +++ b/lms/static/js/dashboard/legacy.js @@ -23,10 +23,7 @@ * Specifically: * - dashboard * - signInUser - * - passwordReset - * - changeEmail * - changeEmailSettings - * - changeName * - verifyToggleBannerFailedOff */ edx.dashboard.legacy.init = function(urls) { @@ -160,67 +157,6 @@ } }); - $('#pwd_reset_button').click(function() { - $.post( - urls.passwordReset, - {"email" : $('#id_email').val()}, - function() { - $("#password_reset_complete_link").click(); - } - ); - }); - - $("#submit-lang").click(function(event) { - event.preventDefault(); - $.post('/lang_pref/setlang/', - {language: $('#settings-language-value').val()} - ).done(function() { - // submit form as normal - $('.settings-language-form').submit(); - }); - }); - - $("#change_email_form").submit(function(){ - var new_email = $('#new_email_field').val(); - var new_password = $('#new_email_password').val(); - - $.post( - urls.changeEmail, - {"new_email" : new_email, "password" : new_password}, - function(data) { - if (data.success) { - $("#change_email_title").html(gettext("Please verify your new email address")); - $("#change_email_form").html( - "

" + - gettext("You'll receive a confirmation in your inbox. Please follow the link in the email to confirm your email address change.") + - "

" - ); - } else { - $("#change_email_error").html(data.error).stop().css("display", "block"); - } - } - ); - return false; - }); - - $("#change_name_form").submit(function(){ - var new_name = $('#new_name_field').val(); - var rationale = $('#name_rationale_field').val(); - - $.post( - urls.changeName, - {"new_name":new_name, "rationale":rationale}, - function(data) { - if(data.success) { - location.reload(); - } else { - $("#change_name_error").html(data.error).stop().css("display", "block"); - } - } - ); - return false; - }); - $("#email_settings_form").submit(function(){ $.ajax({ type: "POST", @@ -240,24 +176,6 @@ return false; }); - accessibleModal( - ".edit-name", - "#apply_name_change .close-modal", - "#apply_name_change", - "#dashboard-main" - ); - accessibleModal( - ".edit-email", - "#change_email .close-modal", - "#change_email", - "#dashboard-main" - ); - accessibleModal( - "#pwd_reset_button", - "#password_reset_complete .close-modal", - "#password_reset_complete", - "#dashboard-main" - ); $(".action-email-settings").each(function(index){ $(this).attr("id", "email-settings-" + index); 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 000000000000..40ae15e5ba97 --- /dev/null +++ b/lms/static/js/fixtures/student_profile/student_profile.html @@ -0,0 +1,20 @@ +
+
+
+

+ + + + + + Loading + +

+
+ +
diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index d3932fc64b60..05b9357134dc 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -30,6 +30,7 @@ 'backbone': 'xmodule_js/common_static/js/vendor/backbone-min', 'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min', 'backbone.paginator': 'xmodule_js/common_static/js/vendor/backbone.paginator.min', + "backbone-super": "js/vendor/backbone-super", 'tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min', 'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce', 'xmodule': 'xmodule_js/src/xmodule', @@ -58,6 +59,7 @@ // Manually specify LMS files that are not converted to RequireJS 'history': 'js/vendor/history', + 'js/mustache': 'js/mustache', 'js/verify_student/photocapture': 'js/verify_student/photocapture', 'js/staff_debug_actions': 'js/staff_debug_actions', 'js/vendor/jquery.qubit': 'js/vendor/jquery.qubit', @@ -88,6 +90,9 @@ 'js/student_account/views/RegisterView': 'js/student_account/views/RegisterView', 'js/student_account/views/AccessView': 'js/student_account/views/AccessView', 'js/student_profile/profile': 'js/student_profile/profile', + 'js/student_profile/views/learner_profile_fields': 'js/student_profile/views/learner_profile_fields', + 'js/student_profile/views/learner_profile_factory': 'js/student_profile/views/learner_profile_factory', + 'js/student_profile/views/learner_profile_view': 'js/student_profile/views/learner_profile_view', // edxnotes 'annotator_1.2.9': 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min' @@ -197,6 +202,9 @@ deps: ['backbone'], exports: 'Backbone.Paginator' }, + "backbone-super": { + deps: ["backbone"], + }, 'youtube': { exports: 'YT' }, @@ -583,7 +591,14 @@ 'lms/include/js/spec/student_account/enrollment_spec.js', 'lms/include/js/spec/student_account/emailoptin_spec.js', 'lms/include/js/spec/student_account/shoppingcart_spec.js', + 'lms/include/js/spec/student_account/account_settings_factory_spec.js', + 'lms/include/js/spec/student_account/account_settings_fields_spec.js', + 'lms/include/js/spec/student_account/account_settings_view_spec.js', 'lms/include/js/spec/student_profile/profile_spec.js', + 'lms/include/js/spec/views/fields_spec.js', + 'lms/include/js/spec/student_profile/learner_profile_factory_spec.js', + 'lms/include/js/spec/student_profile/learner_profile_view_spec.js', + 'lms/include/js/spec/student_profile/learner_profile_fields_spec.js', 'lms/include/js/spec/verify_student/pay_and_verify_view_spec.js', 'lms/include/js/spec/verify_student/webcam_photo_view_spec.js', 'lms/include/js/spec/verify_student/image_input_spec.js', diff --git a/lms/static/js/spec/student_account/account_settings_factory_spec.js b/lms/static/js/spec/student_account/account_settings_factory_spec.js new file mode 100644 index 000000000000..6442e400f640 --- /dev/null +++ b/lms/static/js/spec/student_account/account_settings_factory_spec.js @@ -0,0 +1,196 @@ +define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers', + 'js/spec/views/fields_helpers', + 'js/spec/student_account/helpers', + 'js/spec/student_account/account_settings_fields_helpers', + 'js/student_account/views/account_settings_factory', + 'js/student_account/views/account_settings_view' + ], + function (Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViewsSpecHelpers, Helpers, + AccountSettingsFieldViewSpecHelpers, AccountSettingsPage) { + 'use strict'; + + describe("edx.user.AccountSettingsFactory", function () { + + var FIELDS_DATA = { + 'country': { + 'options': Helpers.FIELD_OPTIONS + }, 'gender': { + 'options': Helpers.FIELD_OPTIONS + }, 'language': { + 'options': Helpers.FIELD_OPTIONS + }, 'level_of_education': { + 'options': Helpers.FIELD_OPTIONS + }, 'password': { + 'url': '/password_reset' + }, 'year_of_birth': { + 'options': Helpers.FIELD_OPTIONS + }, 'preferred_language': { + 'options': Helpers.FIELD_OPTIONS + } + }; + + var AUTH_DATA = { + 'providers': [ + { + 'name': "Network1", + 'connected': true, + 'connect_url': 'yetanother1.com/auth/connect', + 'disconnect_url': 'yetanother1.com/auth/disconnect' + }, + { + 'name': "Network2", + 'connected': true, + 'connect_url': 'yetanother2.com/auth/connect', + 'disconnect_url': 'yetanother2.com/auth/disconnect' + } + ] + }; + + var requests; + + beforeEach(function () { + setFixtures(''); + TemplateHelpers.installTemplate('templates/fields/field_readonly'); + TemplateHelpers.installTemplate('templates/fields/field_dropdown'); + TemplateHelpers.installTemplate('templates/fields/field_link'); + TemplateHelpers.installTemplate('templates/fields/field_text'); + TemplateHelpers.installTemplate('templates/student_account/account_settings'); + }); + + it("shows loading error when UserAccountModel fails to load", function() { + + requests = AjaxHelpers.requests(this); + + var context = AccountSettingsPage( + FIELDS_DATA, AUTH_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL + ); + var accountSettingsView = context.accountSettingsView; + + Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, true); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView); + + var request = requests[0]; + expect(request.method).toBe('GET'); + expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL); + + AjaxHelpers.respondWithError(requests, 500); + Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, false); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); + Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView); + }); + + + it("shows loading error when UserPreferencesModel fails to load", function() { + + requests = AjaxHelpers.requests(this); + + var context = AccountSettingsPage( + FIELDS_DATA, AUTH_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL + ); + var accountSettingsView = context.accountSettingsView; + + Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, true); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView); + + var request = requests[0]; + expect(request.method).toBe('GET'); + expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL); + + AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); + Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, true); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView); + + request = requests[1]; + expect(request.method).toBe('GET'); + expect(request.url).toBe(Helpers.USER_PREFERENCES_API_URL); + + AjaxHelpers.respondWithError(requests, 500); + Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, false); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); + Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView); + }); + + it("renders fields after the models are successfully fetched", function() { + + requests = AjaxHelpers.requests(this); + + var context = AccountSettingsPage( + FIELDS_DATA, AUTH_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL + ); + var accountSettingsView = context.accountSettingsView; + + Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, true); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView); + + AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); + AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); + + Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, false); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + Helpers.expectSettingsSectionsAndFieldsToBeRendered(accountSettingsView); + }); + + it("expects all fields to behave correctly", function () { + + requests = AjaxHelpers.requests(this); + + var context = AccountSettingsPage( + FIELDS_DATA, AUTH_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL + ); + var accountSettingsView = context.accountSettingsView; + + AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); + AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); + + var sectionsData = accountSettingsView.options.sectionsData; + + expect(sectionsData[0].fields.length).toBe(6); + + var textFields = [sectionsData[0].fields[1], sectionsData[0].fields[2]]; + for (var i = 0; i < textFields.length ; i++) { + + var view = textFields[i].view; + FieldViewsSpecHelpers.verifyTextField(view, { + title: view.options.title, + valueAttribute: view.options.valueAttribute, + helpMessage: view.options.helpMessage, + validValue: 'My Name', + invalidValue1: '', + invalidValue2: '@', + validationError: "Think again!" + }, requests); + } + + expect(sectionsData[1].fields.length).toBe(4); + var dropdownFields = [ + sectionsData[1].fields[0], + sectionsData[1].fields[1], + sectionsData[1].fields[2] + ]; + _.each(dropdownFields, function(field) { + var view = field.view; + FieldViewsSpecHelpers.verifyDropDownField(view, { + title: view.options.title, + valueAttribute: view.options.valueAttribute, + helpMessage: '', + validValue: Helpers.FIELD_OPTIONS[1][0], + invalidValue1: Helpers.FIELD_OPTIONS[2][0], + invalidValue2: Helpers.FIELD_OPTIONS[3][0], + validationError: "Nope, this will not do!" + }, requests); + }); + + var section2Fields = sectionsData[2].fields; + expect(section2Fields.length).toBe(2); + for (var i = 0; i < section2Fields.length; i++) { + + var view = section2Fields[i].view; + AccountSettingsFieldViewSpecHelpers.verifyAuthField(view, view.options, requests); + } + }); + }); + }); diff --git a/lms/static/js/spec/student_account/account_settings_fields_helpers.js b/lms/static/js/spec/student_account/account_settings_fields_helpers.js new file mode 100644 index 000000000000..0ee2fa97492e --- /dev/null +++ b/lms/static/js/spec/student_account/account_settings_fields_helpers.js @@ -0,0 +1,30 @@ +define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers', + 'js/spec/views/fields_helpers', + 'string_utils'], + function (Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViewsSpecHelpers) { + 'use strict'; + + var verifyAuthField = function (view, data, requests) { + var selector = '.u-field-value .u-field-link-title-' + view.options.valueAttribute; + + spyOn(view, 'redirect_to'); + + FieldViewsSpecHelpers.expectTitleAndMessageToContain(view, data.title, data.helpMessage); + expect(view.$(selector).text().trim()).toBe('Unlink'); + view.$(selector).click(); + FieldViewsSpecHelpers.expectMessageContains(view, 'Unlinking'); + AjaxHelpers.expectRequest(requests, 'POST', data.disconnectUrl); + AjaxHelpers.respondWithNoContent(requests); + + expect(view.$(selector).text().trim()).toBe('Link'); + FieldViewsSpecHelpers.expectMessageContains(view, 'Successfully unlinked.'); + + view.$(selector).click(); + FieldViewsSpecHelpers.expectMessageContains(view, 'Linking'); + expect(view.redirect_to).toHaveBeenCalledWith(data.connectUrl); + }; + + return { + verifyAuthField: verifyAuthField + }; + }); 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 new file mode 100644 index 000000000000..1970e9ebcda9 --- /dev/null +++ b/lms/static/js/spec/student_account/account_settings_fields_spec.js @@ -0,0 +1,125 @@ +define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_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, + AccountSettingsFieldViewSpecHelpers, AccountSettingsFieldViews) { + 'use strict'; + + describe("edx.AccountSettingsFieldViews", function () { + + var requests, + timerCallback; + + beforeEach(function () { + TemplateHelpers.installTemplate('templates/fields/field_readonly'); + TemplateHelpers.installTemplate('templates/fields/field_dropdown'); + TemplateHelpers.installTemplate('templates/fields/field_link'); + TemplateHelpers.installTemplate('templates/fields/field_text'); + + timerCallback = jasmine.createSpy('timerCallback'); + jasmine.Clock.useMock(); + }); + + it("sends request to reset password on clicking link in PasswordFieldView", function() { + requests = AjaxHelpers.requests(this); + + var fieldData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.PasswordFieldView, { + linkHref: '/password_reset', + emailAttribute: 'email' + }); + + var view = new AccountSettingsFieldViews.PasswordFieldView(fieldData).render(); + view.$('.u-field-value > a').click(); + AjaxHelpers.expectRequest(requests, 'POST', '/password_reset', "email=legolas%40woodland.middlearth"); + AjaxHelpers.respondWithJson(requests, {"success": "true"}); + FieldViewsSpecHelpers.expectMessageContains( + view, + "We've sent a message to legolas@woodland.middlearth. " + + "Click the link in the message to reset your password." + ); + }); + + it("sends request to /i18n/setlang/ after changing language preference in LanguagePreferenceFieldView", function() { + requests = AjaxHelpers.requests(this); + + var selector = '.u-field-value > select'; + var fieldData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.DropdownFieldView, { + valueAttribute: 'language', + options: FieldViewsSpecHelpers.SELECT_OPTIONS + }); + + var view = new AccountSettingsFieldViews.LanguagePreferenceFieldView(fieldData).render(); + + var data = {'language': FieldViewsSpecHelpers.SELECT_OPTIONS[2][0]}; + view.$(selector).val(data[fieldData.valueAttribute]).change(); + FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); + AjaxHelpers.respondWithNoContent(requests); + + AjaxHelpers.expectRequest( + requests, + 'POST', + '/i18n/setlang/', + 'language=' + data[fieldData.valueAttribute] + ); + AjaxHelpers.respondWithNoContent(requests); + FieldViewsSpecHelpers.expectMessageContains(view, "Your changes have been saved."); + + data = {'language': FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]}; + view.$(selector).val(data[fieldData.valueAttribute]).change(); + FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); + AjaxHelpers.respondWithNoContent(requests); + + AjaxHelpers.expectRequest( + requests, + 'POST', + '/i18n/setlang/', + 'language=' + data[fieldData.valueAttribute] + ); + AjaxHelpers.respondWithError(requests, 500); + FieldViewsSpecHelpers.expectMessageContains( + view, + "You must sign out of edX and sign back in before your language changes take effect." + ); + }); + + it("reads and saves the value correctly for LanguageProficienciesFieldView", function() { + requests = AjaxHelpers.requests(this); + + var selector = '.u-field-value > select'; + var fieldData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.DropdownFieldView, { + valueAttribute: 'language_proficiencies', + options: FieldViewsSpecHelpers.SELECT_OPTIONS + }); + 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]}]}; + view.$(selector).val(FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]).change(); + FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); + AjaxHelpers.respondWithNoContent(requests); + }); + + it("correctly links and unlinks from AuthFieldView", function() { + requests = AjaxHelpers.requests(this); + + var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.LinkFieldView, { + title: 'Yet another social network', + helpMessage: '', + valueAttribute: 'auth-yet-another', + connected: true, + connectUrl: 'yetanother.com/auth/connect', + disconnectUrl: 'yetanother.com/auth/disconnect' + }); + var view = new AccountSettingsFieldViews.AuthFieldView(fieldData).render(); + + AccountSettingsFieldViewSpecHelpers.verifyAuthField(view, fieldData, requests); + }); + }); + }); 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 new file mode 100644 index 000000000000..c8d0aaa5ffae --- /dev/null +++ b/lms/static/js/spec/student_account/account_settings_view_spec.js @@ -0,0 +1,102 @@ +define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_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'; + + describe("edx.user.AccountSettingsView", function () { + + var createAccountSettingsView = function () { + + var model = new UserAccountModel(); + model.set(Helpers.createAccountSettingsData()); + + var sectionsData = [ + { + title: "Basic Account Information", + fields: [ + { + view: new FieldViews.ReadonlyFieldView({ + model: model, + title: "Username", + valueAttribute: "username" + }) + }, + { + view: new FieldViews.TextFieldView({ + model: model, + title: "Full Name", + valueAttribute: "name" + }) + } + ] + }, + { + title: "Additional Information", + fields: [ + { + view: new FieldViews.DropdownFieldView({ + model: model, + title: "Education Completed", + valueAttribute: "level_of_education", + options: Helpers.FIELD_OPTIONS + }) + } + ] + } + ]; + + var accountSettingsView = new AccountSettingsView({ + el: $('.wrapper-account-settings'), + model: model, + sectionsData : sectionsData + }); + + return accountSettingsView; + }; + + beforeEach(function () { + setFixtures(''); + TemplateHelpers.installTemplate('templates/fields/field_readonly'); + TemplateHelpers.installTemplate('templates/fields/field_dropdown'); + TemplateHelpers.installTemplate('templates/fields/field_link'); + TemplateHelpers.installTemplate('templates/fields/field_text'); + TemplateHelpers.installTemplate('templates/student_account/account_settings'); + }); + + it("shows loading error correctly", function() { + + var accountSettingsView = createAccountSettingsView(); + + accountSettingsView.render(); + Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, true); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView); + + accountSettingsView.showLoadingError(); + Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, false); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); + Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView); + }); + + it("renders all fields as expected", function() { + + var accountSettingsView = createAccountSettingsView(); + + accountSettingsView.render(); + Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, true); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView); + + accountSettingsView.renderFields(); + Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, false); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + Helpers.expectSettingsSectionsAndFieldsToBeRendered(accountSettingsView); + }); + + }); + }); diff --git a/lms/static/js/spec/student_account/helpers.js b/lms/static/js/spec/student_account/helpers.js new file mode 100644 index 000000000000..8d0266e5150b --- /dev/null +++ b/lms/static/js/spec/student_account/helpers.js @@ -0,0 +1,126 @@ +define(['underscore'], function(_) { + 'use strict'; + + 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 PROFILE_IMAGE = { + image_url_large: '/media/profile-images/image.jpg', + has_image: true + }; + + var DEFAULT_ACCOUNT_SETTINGS_DATA = { + username: 'student', + name: 'Student', + email: 'student@edx.org', + level_of_education: '', + gender: '', + year_of_birth: '3', // Note: test birth year range is a string from 0-3 + requires_parental_consent: false, + country: '', + language: '', + bio: "About the student", + language_proficiencies: [{code: '1'}], + profile_image: PROFILE_IMAGE + }; + + var createAccountSettingsData = function(options) { + return _.extend(_.extend({}, DEFAULT_ACCOUNT_SETTINGS_DATA), options); + }; + + var DEFAULT_USER_PREFERENCES_DATA = { + 'pref-lang': '2' + }; + + var createUserPreferencesData = function(options) { + return _.extend(_.extend({}, DEFAULT_USER_PREFERENCES_DATA), options); + }; + + var FIELD_OPTIONS = [ + ['0', 'Option 0'], + ['1', 'Option 1'], + ['2', 'Option 2'], + ['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'); + } else { + expect($('.ui-loading-indicator')).toHaveClass('is-hidden'); + } + }; + + var expectLoadingErrorIsVisible = function (view, visible) { + if (visible) { + expect(view.$('.ui-loading-error')).not.toHaveClass('is-hidden'); + } else { + expect(view.$('.ui-loading-error')).toHaveClass('is-hidden'); + } + }; + + var expectElementContainsField = function(element, field) { + var view = field.view; + + var fieldTitle = $(element).find('.u-field-title').text().trim(); + expect(fieldTitle).toBe(view.options.title); + + if ('fieldValue' in view) { + expect(view.fieldValue()).toBe(view.modelValue()); + } else if (view.fieldType === 'link') { + expect($(element).find('a').length).toBe(1); + } else { + throw new Error('Unexpected field type: ' + view.fieldType); + } + }; + + var expectSettingsSectionsButNotFieldsToBeRendered = function (accountSettingsView) { + expectSettingsSectionsAndFieldsToBeRendered(accountSettingsView, false) + }; + + var expectSettingsSectionsAndFieldsToBeRendered = function (accountSettingsView, fieldsAreRendered) { + var sectionsData = accountSettingsView.options.sectionsData; + + var sectionElements = accountSettingsView.$('.section'); + expect(sectionElements.length).toBe(sectionsData.length); + + _.each(sectionElements, function(sectionElement, sectionIndex) { + expect($(sectionElement).find('.section-header').text().trim()).toBe(sectionsData[sectionIndex].title); + + var sectionFieldElements = $(sectionElement).find('.u-field'); + + if (fieldsAreRendered === false) { + expect(sectionFieldElements.length).toBe(0); + } else { + expect(sectionFieldElements.length).toBe(sectionsData[sectionIndex].fields.length); + + _.each(sectionFieldElements, function (sectionFieldElement, fieldIndex) { + expectElementContainsField(sectionFieldElement, sectionsData[sectionIndex].fields[fieldIndex]); + }); + } + }); + }; + + 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, + PROFILE_IMAGE: PROFILE_IMAGE, + createAccountSettingsData: createAccountSettingsData, + createUserPreferencesData: createUserPreferencesData, + FIELD_OPTIONS: FIELD_OPTIONS, + expectLoadingIndicatorIsVisible: expectLoadingIndicatorIsVisible, + expectLoadingErrorIsVisible: expectLoadingErrorIsVisible, + expectElementContainsField: expectElementContainsField, + expectSettingsSectionsButNotFieldsToBeRendered: expectSettingsSectionsButNotFieldsToBeRendered, + expectSettingsSectionsAndFieldsToBeRendered: expectSettingsSectionsAndFieldsToBeRendered, + }; +}); diff --git a/lms/static/js/spec/student_account/register_spec.js b/lms/static/js/spec/student_account/register_spec.js index eed77a687d1e..7b1708bf6b60 100644 --- a/lms/static/js/spec/student_account/register_spec.js +++ b/lms/static/js/spec/student_account/register_spec.js @@ -99,7 +99,7 @@ define([ {value: "", name: "--"}, {value: "p", name: "Doctorate"}, {value: "m", name: "Master's or professional degree"}, - {value: "b", name: "Bachelor's degree"}, + {value: "b", name: "Bachelor's degree"} ], required: false, instructions: 'Select your education level.', @@ -115,7 +115,7 @@ define([ {value: "", name: "--"}, {value: "m", name: "Male"}, {value: "f", name: "Female"}, - {value: "o", name: "Other"}, + {value: "o", name: "Other"} ], required: false, instructions: 'Select your gender.', @@ -131,7 +131,7 @@ define([ {value: "", name: "--"}, {value: 1900, name: "1900"}, {value: 1950, name: "1950"}, - {value: 2014, name: "2014"}, + {value: 2014, name: "2014"} ], required: false, instructions: 'Select your year of birth.', diff --git a/lms/static/js/spec/student_profile/helpers.js b/lms/static/js/spec/student_profile/helpers.js new file mode 100644 index 000000000000..594d9520f284 --- /dev/null +++ b/lms/static/js/spec/student_profile/helpers.js @@ -0,0 +1,113 @@ +define(['underscore'], function(_) { + 'use strict'; + + var expectProfileElementContainsField = function(element, view) { + var $element = $(element); + var fieldTitle = $element.find('.u-field-title').text().trim(); + + if (!_.isUndefined(view.options.title)) { + expect(fieldTitle).toBe(view.options.title); + } + + if ('fieldValue' in view || 'imageUrl' in view) { + if ('imageUrl' in view) { + expect($($element.find('.image-frame')[0]).attr('src')).toBe(view.imageUrl()); + } else if (view.fieldValue()) { + expect(view.fieldValue()).toBe(view.modelValue()); + + } else if ('optionForValue' in view) { + expect($($element.find('.u-field-value .u-field-value-readonly')[0]).text()).toBe(view.displayValue(view.modelValue())); + + }else { + expect($($element.find('.u-field-value .u-field-value-readonly')[0]).text()).toBe(view.modelValue()); + } + } else { + throw new Error('Unexpected field type: ' + view.fieldType); + } + }; + + var expectProfilePrivacyFieldTobeRendered = function(learnerProfileView, othersProfile) { + + var accountPrivacyElement = learnerProfileView.$('.wrapper-profile-field-account-privacy'); + var privacyFieldElement = $(accountPrivacyElement).find('.u-field'); + + if (othersProfile) { + expect(privacyFieldElement.length).toBe(0); + } else { + expect(privacyFieldElement.length).toBe(1); + expectProfileElementContainsField(privacyFieldElement, learnerProfileView.options.accountPrivacyFieldView); + } + }; + + var expectSectionOneTobeRendered = function(learnerProfileView) { + + var sectionOneFieldElements = $(learnerProfileView.$('.wrapper-profile-section-one')).find('.u-field'); + + expect(sectionOneFieldElements.length).toBe(4); + expectProfileElementContainsField(sectionOneFieldElements[0], learnerProfileView.options.profileImageFieldView); + expectProfileElementContainsField(sectionOneFieldElements[1], learnerProfileView.options.usernameFieldView); + + _.each(_.rest(sectionOneFieldElements, 2) , function (sectionFieldElement, fieldIndex) { + expectProfileElementContainsField( + sectionFieldElement, + learnerProfileView.options.sectionOneFieldViews[fieldIndex] + ); + }); + }; + + var expectSectionTwoTobeRendered = function(learnerProfileView) { + + var sectionTwoElement = learnerProfileView.$('.wrapper-profile-section-two'); + var sectionTwoFieldElements = $(sectionTwoElement).find('.u-field'); + + expect(sectionTwoFieldElements.length).toBe(learnerProfileView.options.sectionTwoFieldViews.length); + + _.each(sectionTwoFieldElements, function (sectionFieldElement, fieldIndex) { + expectProfileElementContainsField( + sectionFieldElement, + learnerProfileView.options.sectionTwoFieldViews[fieldIndex] + ); + }); + }; + + var expectProfileSectionsAndFieldsToBeRendered = function (learnerProfileView, othersProfile) { + expectProfilePrivacyFieldTobeRendered(learnerProfileView, othersProfile); + expectSectionOneTobeRendered(learnerProfileView); + expectSectionTwoTobeRendered(learnerProfileView); + }; + + var expectLimitedProfileSectionsAndFieldsToBeRendered = function (learnerProfileView, othersProfile) { + expectProfilePrivacyFieldTobeRendered(learnerProfileView, othersProfile); + + var sectionOneFieldElements = $(learnerProfileView.$('.wrapper-profile-section-one')).find('.u-field'); + + expect(sectionOneFieldElements.length).toBe(2); + expectProfileElementContainsField( + sectionOneFieldElements[0], + learnerProfileView.options.profileImageFieldView + ); + expectProfileElementContainsField( + sectionOneFieldElements[1], + learnerProfileView.options.usernameFieldView + ); + + if (othersProfile) { + expect($('.profile-private--message').text()) + .toBe('This edX learner is currently sharing a limited profile.'); + } else { + expect($('.profile-private--message').text()).toBe('You are currently sharing a limited profile.'); + } + }; + + var expectProfileSectionsNotToBeRendered = function(learnerProfileView) { + expect(learnerProfileView.$('.wrapper-profile-field-account-privacy').length).toBe(0); + expect(learnerProfileView.$('.wrapper-profile-section-one').length).toBe(0); + expect(learnerProfileView.$('.wrapper-profile-section-two').length).toBe(0); + }; + + return { + expectLimitedProfileSectionsAndFieldsToBeRendered: expectLimitedProfileSectionsAndFieldsToBeRendered, + expectProfileSectionsAndFieldsToBeRendered: expectProfileSectionsAndFieldsToBeRendered, + expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered + }; +}); 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 new file mode 100644 index 000000000000..7d3bd8e1bcf6 --- /dev/null +++ b/lms/static/js/spec/student_profile/learner_profile_factory_spec.js @@ -0,0 +1,138 @@ +define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers', + 'js/spec/student_account/helpers', + 'js/spec/student_profile/helpers', + 'js/views/fields', + 'js/student_account/models/user_account_model', + 'js/student_account/models/user_preferences_model', + 'js/student_profile/views/learner_profile_view', + 'js/student_profile/views/learner_profile_fields', + 'js/student_profile/views/learner_profile_factory', + 'js/views/message_banner' + ], + function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews, + UserAccountModel, UserPreferencesModel, LearnerProfileView, LearnerProfileFields, LearnerProfilePage) { + 'use strict'; + + describe("edx.user.LearnerProfileFactory", function () { + + var requests; + + beforeEach(function () { + 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'); + TemplateHelpers.installTemplate('templates/fields/field_image'); + TemplateHelpers.installTemplate('templates/fields/message_banner'); + TemplateHelpers.installTemplate('templates/student_profile/learner_profile'); + }); + + var createProfilePage = function(ownProfile) { + return new LearnerProfilePage({ + 'accounts_api_url': Helpers.USER_ACCOUNTS_API_URL, + 'preferences_api_url': Helpers.USER_PREFERENCES_API_URL, + 'own_profile': ownProfile, + 'account_settings_page_url': Helpers.USER_ACCOUNTS_API_URL, + 'country_options': Helpers.FIELD_OPTIONS, + 'language_options': Helpers.FIELD_OPTIONS, + 'has_preferences_access': true, + 'profile_image_max_bytes': Helpers.IMAGE_MAX_BYTES, + 'profile_image_min_bytes': Helpers.IMAGE_MIN_BYTES, + 'profile_image_upload_url': Helpers.IMAGE_UPLOAD_API_URL, + 'profile_image_remove_url': Helpers.IMAGE_REMOVE_API_URL, + 'default_visibility': 'all_users' + }); + }; + + it("show loading error when UserAccountModel fails to load", function() { + + requests = AjaxHelpers.requests(this); + + var context = createProfilePage(true), + learnerProfileView = context.learnerProfileView; + + var userAccountRequest = requests[0]; + expect(userAccountRequest.method).toBe('GET'); + expect(userAccountRequest.url).toBe(Helpers.USER_ACCOUNTS_API_URL); + + AjaxHelpers.respondWithError(requests, 500); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, true); + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView); + }); + + it("shows loading error when UserPreferencesModel fails to load", function() { + + requests = AjaxHelpers.requests(this); + + var context = createProfilePage(true), + learnerProfileView = context.learnerProfileView; + + var userAccountRequest = requests[0]; + expect(userAccountRequest.method).toBe('GET'); + expect(userAccountRequest.url).toBe(Helpers.USER_ACCOUNTS_API_URL); + + AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView); + + var userPreferencesRequest = requests[1]; + expect(userPreferencesRequest.method).toBe('GET'); + expect(userPreferencesRequest.url).toBe(Helpers.USER_PREFERENCES_API_URL); + + AjaxHelpers.respondWithError(requests, 500); + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, false); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, true); + LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView); + }); + + it("renders the full profile after models are successfully fetched", function() { + + requests = AjaxHelpers.requests(this); + + var context = createProfilePage(true), + learnerProfileView = context.learnerProfileView; + + AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); + AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); + + // sets the profile for full view. + context.accountPreferencesModel.set({account_privacy: 'all_users'}); + LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView, false); + }); + + it("renders the limited profile for undefined 'year_of_birth'", function() { + + requests = AjaxHelpers.requests(this); + + var context = createProfilePage(true), + learnerProfileView = context.learnerProfileView; + + AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData({ + year_of_birth: '', + requires_parental_consent: true + })); + AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); + + LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView); + }); + + it("renders the limited profile for under 13 users", function() { + + requests = AjaxHelpers.requests(this); + + var context = createProfilePage(true), + learnerProfileView = context.learnerProfileView; + + AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData({ + year_of_birth: new Date().getFullYear() - 10, + requires_parental_consent: true + })); + AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); + + LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView); + }); + }); + }); 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 new file mode 100644 index 000000000000..bbf289d263c0 --- /dev/null +++ b/lms/static/js/spec/student_profile/learner_profile_fields_spec.js @@ -0,0 +1,291 @@ +define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers', + 'js/spec/student_account/helpers', + 'js/student_account/models/user_account_model', + 'js/student_profile/views/learner_profile_fields', + 'js/views/message_banner' + ], + function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, UserAccountModel, LearnerProfileFields, + MessageBannerView) { + 'use strict'; + + describe("edx.user.LearnerProfileFields", function () { + + 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: options.hasImage ? true : false + }; + + var accountSettingsModel = new UserAccountModel(); + accountSettingsModel.set({'profile_image': imageData}); + accountSettingsModel.set({'year_of_birth': yearOfBirth}); + accountSettingsModel.set({'requires_parental_consent': _.isEmpty(yearOfBirth) ? true : false}); + + accountSettingsModel.url = Helpers.USER_ACCOUNTS_API_URL; + + var messageView = new MessageBannerView({ + el: $('.message-banner') + }); + + return new LearnerProfileFields.ProfileImageFieldView({ + model: accountSettingsModel, + valueAttribute: "profile_image", + editable: options.ownProfile, + messageView: messageView, + imageMaxBytes: imageMaxBytes, + imageMinBytes: imageMinBytes, + imageUploadUrl: Helpers.IMAGE_UPLOAD_API_URL, + imageRemoveUrl: Helpers.IMAGE_REMOVE_API_URL + }); + }; + + beforeEach(function () { + 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"); + }); + + var createFakeImageFile = function (size) { + var fileFakeData = 'i63ljc6giwoskyb9x5sw0169bdcmcxr3cdz8boqv0lik971972cmd6yknvcxr5sw0nvc169bdcmcxsdf'; + return new Blob( + [ fileFakeData.substr(0, size) ], + { type: 'image/jpg' } + ); + }; + + var initializeUploader = function (view) { + view.$('.upload-button-input').fileupload({ + url: Helpers.IMAGE_UPLOAD_API_URL, + type: 'POST', + 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); + }); + + it("can remove profile image", function() { + + var imageView = createImageView({ownProfile: true, hasImage: false}); + imageView.render(); + + var requests = AjaxHelpers.requests(this); + + // Verify image remove title + verifyImageRemoveButtonMessage(imageView, false); + + imageView.$('.u-field-remove-button').click(); + + // Verify image remove progress message + verifyImageRemoveButtonMessage(imageView, true); + + // 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 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({ownProfile: true, hasImage: 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({ownProfile: true, hasImage: false}); + imageView.render(); + + initializeUploader(imageView); + + // Add image to upload queue, this will validate the image size + imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(70)]}); + + // Verify error message + expect($('.message-banner').text().trim()) + .toBe('Your image must be smaller than 64 bytes in size.'); + }); + + it("can't upload image having size less than min size", function() { + var imageView = createImageView({ownProfile: true, hasImage: false}); + imageView.render(); + + initializeUploader(imageView); + + // Add image to upload queue, this will validate the image size + imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(10)]}); + + // Verify error message + expect($('.message-banner').text().trim()).toBe('Your image must be at least 16 bytes in size.'); + }); + + it("can't upload and remove image if parental consent required", function() { + + 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 new file mode 100644 index 000000000000..2ee04608ee83 --- /dev/null +++ b/lms/static/js/spec/student_profile/learner_profile_view_spec.js @@ -0,0 +1,198 @@ +define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers', + 'js/spec/student_account/helpers', + 'js/spec/student_profile/helpers', + 'js/views/fields', + 'js/student_account/models/user_account_model', + '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/views/message_banner' + ], + function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews, + UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView, + AccountSettingsFieldViews, MessageBannerView) { + 'use strict'; + + describe("edx.user.LearnerProfileView", function () { + + var createLearnerProfileView = function (ownProfile, accountPrivacy, profileIsPublic) { + + var accountSettingsModel = new UserAccountModel(); + accountSettingsModel.set(Helpers.createAccountSettingsData()); + accountSettingsModel.set({'profile_is_public': profileIsPublic}); + accountSettingsModel.set({'profile_image': Helpers.PROFILE_IMAGE}); + + var accountPreferencesModel = new AccountPreferencesModel(); + accountPreferencesModel.set({account_privacy: accountPrivacy}); + + accountPreferencesModel.url = Helpers.USER_PREFERENCES_API_URL; + + var editable = ownProfile ? 'toggle' : 'never'; + + var accountPrivacyFieldView = new LearnerProfileFields.AccountPrivacyFieldView({ + model: accountPreferencesModel, + required: true, + editable: 'always', + showMessages: false, + title: 'edX learners can see my:', + valueAttribute: "account_privacy", + options: [ + ['all_users', 'Full Profile'], + ['private', 'Limited Profile'] + ], + helpMessage: '', + accountSettingsPageUrl: '/account/settings/' + }); + + var messageView = new MessageBannerView({ + el: $('.message-banner') + }); + + var profileImageFieldView = new LearnerProfileFields.ProfileImageFieldView({ + 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", + helpMessage: "" + }); + + var sectionOneFieldViews = [ + new FieldViews.DropdownFieldView({ + model: accountSettingsModel, + required: false, + editable: editable, + showMessages: false, + iconName: 'fa-map-marker', + placeholderValue: '', + valueAttribute: "country", + options: Helpers.FIELD_OPTIONS, + helpMessage: '' + }), + + new AccountSettingsFieldViews.LanguageProficienciesFieldView({ + model: accountSettingsModel, + required: false, + editable: editable, + showMessages: false, + iconName: 'fa-comment', + placeholderValue: 'Add language', + valueAttribute: "language_proficiencies", + options: Helpers.FIELD_OPTIONS, + helpMessage: '' + }) + ]; + + var sectionTwoFieldViews = [ + new FieldViews.TextareaFieldView({ + model: accountSettingsModel, + editable: editable, + showMessages: false, + title: 'About me', + placeholderValue: "Tell other edX learners a little about yourself: where you live, " + + "what your interests are, why you're taking courses on edX, or what you hope to learn.", + valueAttribute: "bio", + helpMessage: '' + }) + ]; + + return new LearnerProfileView( + { + el: $('.wrapper-profile'), + ownProfile: ownProfile, + hasPreferencesAccess: true, + accountSettingsModel: accountSettingsModel, + preferencesModel: accountPreferencesModel, + accountPrivacyFieldView: accountPrivacyFieldView, + usernameFieldView: usernameFieldView, + profileImageFieldView: profileImageFieldView, + sectionOneFieldViews: sectionOneFieldViews, + sectionTwoFieldViews: sectionTwoFieldViews + }); + }; + + beforeEach(function () { + 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'); + TemplateHelpers.installTemplate('templates/fields/field_image'); + TemplateHelpers.installTemplate('templates/fields/message_banner'); + TemplateHelpers.installTemplate('templates/student_profile/learner_profile'); + }); + + it("shows loading error correctly", function() { + + var learnerProfileView = createLearnerProfileView(false, 'all_users'); + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + + learnerProfileView.render(); + learnerProfileView.showLoadingError(); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, true); + }); + + it("renders all fields as expected for self with full access", function() { + + var learnerProfileView = createLearnerProfileView(true, 'all_users', true); + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + + learnerProfileView.render(); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView); + }); + + it("renders all fields as expected for self with limited access", function() { + + var learnerProfileView = createLearnerProfileView(true, 'private', false); + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + + learnerProfileView.render(); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView); + }); + + it("renders the fields as expected for others with full access", function() { + + var learnerProfileView = createLearnerProfileView(false, 'all_users', true); + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + + learnerProfileView.render(); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView, true); + }); + + it("renders the fields as expected for others with limited access", function() { + + var learnerProfileView = createLearnerProfileView(false, 'private', false); + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + + learnerProfileView.render(); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView, true); + }); + }); + }); diff --git a/lms/static/js/spec/views/fields_helpers.js b/lms/static/js/spec/views/fields_helpers.js new file mode 100644 index 000000000000..6898ffa06744 --- /dev/null +++ b/lms/static/js/spec/views/fields_helpers.js @@ -0,0 +1,233 @@ +define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers', + 'js/views/fields', + 'string_utils'], + function (Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViews) { + 'use strict'; + + var API_URL = '/api/end_point/v1'; + + var USERNAME = 'Legolas', + FULLNAME = 'Legolas Thranduil', + EMAIL = 'legolas@woodland.middlearth', + SELECT_OPTIONS = [['si', 'sindarin'], ['el', 'elvish'], ['na', 'nandor']]; + + var UserAccountModel = Backbone.Model.extend({ + idAttribute: 'username', + defaults: { + username: USERNAME, + name: FULLNAME, + email: EMAIL, + language: SELECT_OPTIONS[0][0] + }, + url: API_URL + }); + + var createFieldData = function (fieldType, fieldData) { + var data = { + model: fieldData.model || new UserAccountModel({}), + title: fieldData.title || 'Field Title', + valueAttribute: fieldData.valueAttribute, + helpMessage: fieldData.helpMessage || 'I am a field message', + placeholderValue: fieldData.placeholderValue || 'I am a placeholder message' + }; + + switch (fieldType) { + case FieldViews.DropdownFieldView: + data['required'] = fieldData.required || false; + data['options'] = fieldData.options || SELECT_OPTIONS; + break; + case FieldViews.LinkFieldView: + case FieldViews.PasswordFieldView: + data['linkTitle'] = fieldData.linkTitle || "Link Title"; + data['linkHref'] = fieldData.linkHref || "/path/to/resource"; + data['emailAttribute'] = 'email'; + break; + } + + _.extend(data, fieldData); + + return data; + }; + + var createErrorMessage = function(attribute, user_message) { + var field_errors = {}; + field_errors[attribute] = { + "user_message": user_message + }; + return { + "field_errors": field_errors + }; + }; + + var expectTitleToContain = function(view, expectedTitle) { + expect(view.$('.u-field-title').text().trim()).toContain(expectedTitle); + }; + + var expectMessageContains = function(view, expectedText) { + expect(view.$('.u-field-message').html()).toContain(expectedText); + }; + + var expectTitleAndMessageToContain = function(view, expectedTitle, expectedMessage) { + expectTitleToContain(view, expectedTitle); + expectMessageContains(view, expectedMessage); + }; + + var expectAjaxRequestWithData = function(requests, data) { + AjaxHelpers.expectJsonRequest( + requests, 'PATCH', API_URL, data + ); + }; + + var verifyMessageUpdates = function (view, data, timerCallback) { + + var message = 'Here to help!'; + + view.showHelpMessage(message); + expectMessageContains(view, message); + + view.showHelpMessage(); + expectMessageContains(view, view.helpMessage); + + view.showInProgressMessage(); + expectMessageContains(view, view.indicators.inProgress); + expectMessageContains(view, view.messages.inProgress); + + view.showSuccessMessage(); + expectMessageContains(view, view.indicators.success); + expectMessageContains(view, view.getMessage('success')); + + expect(timerCallback).not.toHaveBeenCalled(); + + view.showErrorMessage({ + responseText: JSON.stringify(createErrorMessage(data.valueAttribute, 'Ops, try again!.')), + status: 400 + }); + expectMessageContains(view, view.indicators.validationError); + + view.showErrorMessage({status: 500}); + expectMessageContains(view, view.indicators.error); + expectMessageContains(view, view.indicators.error); + }; + + var verifySuccessMessageReset = function (view) { + view.showHelpMessage(); + expectMessageContains(view, view.helpMessage); + view.showSuccessMessage(); + expectMessageContains(view, view.indicators.success); + jasmine.Clock.tick(5000); + // Message gets reset + expectMessageContains(view, view.helpMessage); + + view.showSuccessMessage(); + expectMessageContains(view, view.indicators.success); + // But if we change the message, it should not get reset. + view.showHelpMessage("Do not reset this!"); + jasmine.Clock.tick(5000); + expectMessageContains(view, "Do not reset this!"); + }; + + var verifyEditableField = function (view, data, requests) { + var request_data = {}; + var url = view.model.url; + + if (data.editable === 'toggle') { + expect(view.el).toHaveClass('mode-placeholder'); + expectTitleToContain(view, data.title); + expectMessageContains(view, view.indicators.canEdit); + view.$el.click(); + } else { + expectTitleAndMessageToContain(view, data.title, data.helpMessage, false); + } + + expect(view.el).toHaveClass('mode-edit'); + expect(view.fieldValue()).not.toContain(data.validValue); + + view.$(data.valueInputSelector).val(data.validValue).change(); + // When the value in the field is changed + expect(view.fieldValue()).toBe(data.validValue); + expectMessageContains(view, view.indicators.inProgress); + expectMessageContains(view, view.messages.inProgress); + request_data[data.valueAttribute] = data.validValue; + AjaxHelpers.expectJsonRequest( + requests, 'PATCH', url, request_data + ); + + AjaxHelpers.respondWithNoContent(requests); + // When server returns success. + if (data.editable === 'toggle') { + expect(view.el).toHaveClass('mode-display'); + view.$el.click(); + } else { + expectMessageContains(view, view.indicators.success); + } + + view.$(data.valueInputSelector).val(data.invalidValue1).change(); + request_data[data.valueAttribute] = data.invalidValue1; + AjaxHelpers.expectJsonRequest( + requests, 'PATCH', url, request_data + ); + AjaxHelpers.respondWithError(requests, 500); + // When server returns a 500 error + expectMessageContains(view, view.indicators.error); + expectMessageContains(view, view.messages.error); + expect(view.el).toHaveClass('mode-edit'); + + view.$(data.valueInputSelector).val(data.invalidValue2).change(); + request_data[data.valueAttribute] = data.invalidValue2; + AjaxHelpers.expectJsonRequest( + requests, 'PATCH', url, request_data + ); + AjaxHelpers.respondWithError(requests, 400, createErrorMessage(data.valueAttribute, data.validationError)); + // When server returns a validation error + expectMessageContains(view, view.indicators.validationError); + expectMessageContains(view, data.validationError); + expect(view.el).toHaveClass('mode-edit'); + + view.$(data.valueInputSelector).val('').change(); + // When the value in the field is changed + expect(view.fieldValue()).toBe(''); + request_data[data.valueAttribute] = ''; + AjaxHelpers.expectJsonRequest( + requests, 'PATCH', url, request_data + ); + AjaxHelpers.respondWithNoContent(requests); + // When server returns success. + if (data.editable === 'toggle') { + expect(view.el).toHaveClass('mode-placeholder'); + } else { + expect(view.el).toHaveClass('mode-edit'); + } + }; + + var verifyTextField = function (view, data, requests) { + verifyEditableField(view, _.extend({ + valueSelector: '.u-field-value', + valueInputSelector: '.u-field-value > input' + }, data + ), requests); + }; + + var verifyDropDownField = function (view, data, requests) { + verifyEditableField(view, _.extend({ + valueSelector: '.u-field-value', + valueInputSelector: '.u-field-value > select' + }, data + ), requests); + }; + + return { + SELECT_OPTIONS: SELECT_OPTIONS, + UserAccountModel: UserAccountModel, + createFieldData: createFieldData, + createErrorMessage: createErrorMessage, + expectTitleToContain: expectTitleToContain, + expectTitleAndMessageToContain: expectTitleAndMessageToContain, + expectMessageContains: expectMessageContains, + expectAjaxRequestWithData: expectAjaxRequestWithData, + verifyMessageUpdates: verifyMessageUpdates, + verifySuccessMessageReset: verifySuccessMessageReset, + verifyEditableField: verifyEditableField, + verifyTextField: verifyTextField, + verifyDropDownField: verifyDropDownField + }; + }); diff --git a/lms/static/js/spec/views/fields_spec.js b/lms/static/js/spec/views/fields_spec.js new file mode 100644 index 000000000000..06a3e6bb7437 --- /dev/null +++ b/lms/static/js/spec/views/fields_spec.js @@ -0,0 +1,301 @@ +define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers', + 'js/views/fields', + 'js/spec/views/fields_helpers', + 'string_utils'], + function (Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViews, FieldViewsSpecHelpers) { + 'use strict'; + + var USERNAME = 'Legolas', + BIO = "My Name is Theon Greyjoy. I'm member of House Greyjoy"; + + describe("edx.FieldViews", function () { + + var requests, + timerCallback; + + var fieldViewClasses = [ + FieldViews.ReadonlyFieldView, + FieldViews.TextFieldView, + FieldViews.DropdownFieldView, + FieldViews.LinkFieldView, + FieldViews.TextareaFieldView + + ]; + + beforeEach(function () { + TemplateHelpers.installTemplate('templates/fields/field_readonly'); + TemplateHelpers.installTemplate('templates/fields/field_dropdown'); + TemplateHelpers.installTemplate('templates/fields/field_link'); + TemplateHelpers.installTemplate('templates/fields/field_text'); + TemplateHelpers.installTemplate('templates/fields/field_textarea'); + + timerCallback = jasmine.createSpy('timerCallback'); + jasmine.Clock.useMock(); + }); + + it("updates messages correctly for all fields", function() { + + for (var i = 0; i < fieldViewClasses.length; i++) { + + var fieldViewClass = fieldViewClasses[i]; + var fieldData = FieldViewsSpecHelpers.createFieldData(fieldViewClass, { + title: 'Username', + valueAttribute: 'username', + helpMessage: 'The username that you use to sign in to edX.' + }); + + var view = new fieldViewClass(fieldData).render(); + FieldViewsSpecHelpers.verifyMessageUpdates(view, fieldData, timerCallback); + } + }); + + it("resets to help message some time after success message is set", function() { + + for (var i = 0; i < fieldViewClasses.length; i++) { + var fieldViewClass = fieldViewClasses[i]; + var fieldData = FieldViewsSpecHelpers.createFieldData(fieldViewClass, { + title: 'Username', + valueAttribute: 'username', + helpMessage: 'The username that you use to sign in to edX.' + }); + + var view = new fieldViewClass(fieldData).render(); + FieldViewsSpecHelpers.verifySuccessMessageReset(view, fieldData, timerCallback); + } + }); + + it("sends a PATCH request when saveAttributes is called", function() { + + requests = AjaxHelpers.requests(this); + + var fieldViewClass = FieldViews.EditableFieldView; + var fieldData = FieldViewsSpecHelpers.createFieldData(fieldViewClass, { + title: 'Preferred Language', + valueAttribute: 'language', + helpMessage: 'Your preferred language.' + }); + + var view = new fieldViewClass(fieldData); + view.saveAttributes( + {'language': 'ur'}, + {'headers': {'Priority': 'Urgent'}} + ); + + var request = requests[0]; + expect(request.method).toBe('PATCH'); + expect(request.requestHeaders['Content-Type']).toBe('application/merge-patch+json;charset=utf-8'); + expect(request.requestHeaders.Priority).toBe('Urgent'); + expect(request.requestBody).toBe('{"language":"ur"}'); + }); + + it("correctly renders and updates ReadonlyFieldView", function() { + var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.ReadonlyFieldView, { + title: 'Username', + valueAttribute: 'username', + helpMessage: 'The username that you use to sign in to edX.' + }); + var view = new FieldViews.ReadonlyFieldView(fieldData).render(); + + FieldViewsSpecHelpers.expectTitleAndMessageToContain(view, fieldData.title, fieldData.helpMessage, false); + expect(view.$('.u-field-value input').val().trim()).toBe(USERNAME); + + view.model.set({'username': 'bookworm'}); + expect(view.$('.u-field-value input').val().trim()).toBe('bookworm'); + }); + + it("correctly renders, updates and persists changes to TextFieldView when editable == always", function() { + + requests = AjaxHelpers.requests(this); + + var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.TextFieldView, { + title: 'Full Name', + valueAttribute: 'name', + helpMessage: 'How are you?' + }); + var view = new FieldViews.TextFieldView(fieldData).render(); + + FieldViewsSpecHelpers.verifyTextField(view, { + title: fieldData.title, + valueAttribute: fieldData.valueAttribute, + helpMessage: fieldData.helpMessage, + validValue: 'My Name', + invalidValue1: 'Your Name', + invalidValue2: 'Her Name', + validationError: "Think again!" + }, requests); + }); + + it("correctly renders and updates DropdownFieldView when editable == never", function() { + + requests = AjaxHelpers.requests(this); + + var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.DropdownFieldView, { + title: 'Full Name', + valueAttribute: 'name', + helpMessage: 'edX full name', + editable: 'never' + + }); + var view = new FieldViews.DropdownFieldView(fieldData).render(); + FieldViewsSpecHelpers.expectTitleAndMessageToContain(view, fieldData.title, fieldData.helpMessage, false); + expect(view.el).toHaveClass('mode-hidden'); + + view.model.set({'name': fieldData.options[1][0]}); + expect(view.el).toHaveClass('mode-display'); + view.$el.click(); + expect(view.el).toHaveClass('mode-display'); + }); + + it("correctly renders, updates and persists changes to DropdownFieldView when editable == always", function() { + + requests = AjaxHelpers.requests(this); + + var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.DropdownFieldView, { + title: 'Full Name', + valueAttribute: 'name', + helpMessage: 'edX full name' + }); + var view = new FieldViews.DropdownFieldView(fieldData).render(); + + FieldViewsSpecHelpers.verifyDropDownField(view, { + title: fieldData.title, + valueAttribute: fieldData.valueAttribute, + helpMessage: fieldData.helpMessage, + validValue: FieldViewsSpecHelpers.SELECT_OPTIONS[0][0], + invalidValue1: FieldViewsSpecHelpers.SELECT_OPTIONS[1][0], + invalidValue2: FieldViewsSpecHelpers.SELECT_OPTIONS[2][0], + validationError: "Nope, this will not do!" + }, requests); + }); + + it("correctly renders, updates and persists changes to DropdownFieldView when editable == toggle", function() { + + requests = AjaxHelpers.requests(this); + + var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.DropdownFieldView, { + title: 'Full Name', + valueAttribute: 'name', + helpMessage: 'edX full name', + editable: 'toggle' + }); + var view = new FieldViews.DropdownFieldView(fieldData).render(); + + FieldViewsSpecHelpers.verifyDropDownField(view, { + title: fieldData.title, + valueAttribute: fieldData.valueAttribute, + helpMessage: fieldData.helpMessage, + editable: 'toggle', + validValue: FieldViewsSpecHelpers.SELECT_OPTIONS[0][0], + invalidValue1: FieldViewsSpecHelpers.SELECT_OPTIONS[1][0], + invalidValue2: FieldViewsSpecHelpers.SELECT_OPTIONS[2][0], + validationError: "Nope, this will not do!" + }, requests); + }); + + it("only shows empty option in DropdownFieldView if required is false or model value is not set", function() { + requests = AjaxHelpers.requests(this); + + var editableOptions = ['toggle', 'always']; + _.each(editableOptions, function(editable) { + var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.DropdownFieldView, { + title: 'Drop Down Field', + valueAttribute: 'drop-down', + helpMessage: 'edX drop down', + editable: editable, + required:true + }); + var view = new FieldViews.DropdownFieldView(fieldData).render(); + + expect(view.modelValueIsSet()).toBe(false); + expect(view.displayValue()).toBe(''); + + if(editable === 'toggle') { view.showEditMode(true); } + view.$('.u-field-value > select').val(FieldViewsSpecHelpers.SELECT_OPTIONS[0]).change(); + expect(view.fieldValue()).toBe(FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]); + + AjaxHelpers.respondWithNoContent(requests); + if(editable === 'toggle') { view.showEditMode(true); } + // When server returns success, there should no longer be an empty option. + expect($(view.$('.u-field-value option')[0]).val()).toBe('si'); + }); + }); + + it("correctly renders and updates TextAreaFieldView when editable == never", function() { + var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.TextareaFieldView, { + title: 'About me', + valueAttribute: 'bio', + helpMessage: 'Wicked is good', + placeholderValue: "Tell other edX learners a little about yourself: where you live, " + + "what your interests are, why you’re taking courses on edX, or what you hope to learn.", + editable: 'never' + }); + + // set bio to empty to see the placeholder. + fieldData.model.set({bio: ''}); + var view = new FieldViews.TextareaFieldView(fieldData).render(); + FieldViewsSpecHelpers.expectTitleAndMessageToContain(view, fieldData.title, fieldData.helpMessage, false); + expect(view.el).toHaveClass('mode-hidden'); + expect(view.$('.u-field-value .u-field-value-readonly').text()).toBe(fieldData.placeholderValue); + + var bio = 'Too much to tell!'; + view.model.set({'bio': bio}); + expect(view.el).toHaveClass('mode-display'); + expect(view.$('.u-field-value .u-field-value-readonly').text()).toBe(bio); + view.$el.click(); + expect(view.el).toHaveClass('mode-display'); + }); + + it("correctly renders, updates and persists changes to TextAreaFieldView when editable == toggle", function() { + + requests = AjaxHelpers.requests(this); + + var valueInputSelector = '.u-field-value > textarea'; + var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.TextareaFieldView, { + title: 'About me', + valueAttribute: 'bio', + helpMessage: 'Wicked is good', + placeholderValue: "Tell other edX learners a little about yourself: where you live, " + + "what your interests are, why you’re taking courses on edX, or what you hope to learn.", + editable: 'toggle' + + }); + fieldData.model.set({'bio': ''}); + + var view = new FieldViews.TextareaFieldView(fieldData).render(); + + FieldViewsSpecHelpers.expectTitleToContain(view, fieldData.title); + FieldViewsSpecHelpers.expectMessageContains(view, view.indicators.canEdit); + expect(view.el).toHaveClass('mode-placeholder'); + expect(view.$('.u-field-value .u-field-value-readonly').text()).toBe(fieldData.placeholderValue); + + view.$('.wrapper-u-field').click(); + expect(view.el).toHaveClass('mode-edit'); + view.$(valueInputSelector).val(BIO).focusout(); + expect(view.fieldValue()).toBe(BIO); + AjaxHelpers.expectJsonRequest( + requests, 'PATCH', view.model.url, {'bio': BIO} + ); + AjaxHelpers.respondWithNoContent(requests); + expect(view.el).toHaveClass('mode-display'); + + view.$('.wrapper-u-field').click(); + view.$(valueInputSelector).val('').focusout(); + AjaxHelpers.respondWithNoContent(requests); + expect(view.el).toHaveClass('mode-placeholder'); + expect(view.$('.u-field-value .u-field-value-readonly').text()).toBe(fieldData.placeholderValue); + }); + + it("correctly renders LinkFieldView", function() { + var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.LinkFieldView, { + title: 'Title', + linkTitle: 'Link title', + helpMessage: 'Click the link.', + valueAttribute: 'password-reset' + }); + var view = new FieldViews.LinkFieldView(fieldData).render(); + + FieldViewsSpecHelpers.expectTitleAndMessageToContain(view, fieldData.title, fieldData.helpMessage, false); + expect(view.$('.u-field-value > a .u-field-link-title-' + view.options.valueAttribute).text().trim()).toBe(fieldData.linkTitle); + }); + }); + }); 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 000000000000..b5f01cd3ac14 --- /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_account/models/user_account_model.js b/lms/static/js/student_account/models/user_account_model.js new file mode 100644 index 000000000000..e847d0ba8881 --- /dev/null +++ b/lms/static/js/student_account/models/user_account_model.js @@ -0,0 +1,59 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'underscore', 'backbone' + ], function (gettext, _, Backbone) { + + var UserAccountModel = Backbone.Model.extend({ + idAttribute: 'username', + defaults: { + username: '', + name: '', + email: '', + password: '', + language: null, + country: null, + date_joined: "", + gender: null, + goals: "", + level_of_education: null, + mailing_address: "", + year_of_birth: null, + bio: null, + language_proficiencies: [], + requires_parental_consent: true, + profile_image: null, + default_public_account_fields: [] + }, + + parse : function(response) { + if (_.isNull(response)) { + return {}; + } + + // Currently when a non-staff user A access user B's profile, the only way to tell whether user B's + // profile is public is to check if the api has returned fields other than the default public fields + // specified in settings.ACCOUNT_VISIBILITY_CONFIGURATION. + var profileIsPublic = _.size(_.difference(_.keys(response), this.get('default_public_account_fields'))) > 0; + this.set({'profile_is_public': profileIsPublic}, { silent: true }); + + 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")); + } + }); + return UserAccountModel; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_account/models/user_preferences_model.js b/lms/static/js/student_account/models/user_preferences_model.js new file mode 100644 index 000000000000..af421013b33e --- /dev/null +++ b/lms/static/js/student_account/models/user_preferences_model.js @@ -0,0 +1,16 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'underscore', 'backbone' + ], function (gettext, _, Backbone) { + + var UserPreferencesModel = Backbone.Model.extend({ + idAttribute: 'account_privacy', + defaults: { + account_privacy: 'private' + } + }); + + return UserPreferencesModel; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_account/views/account_settings_factory.js b/lms/static/js/student_account/views/account_settings_factory.js new file mode 100644 index 000000000000..af4659968825 --- /dev/null +++ b/lms/static/js/student_account/views/account_settings_factory.js @@ -0,0 +1,190 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone', 'logger', + 'js/views/fields', + 'js/student_account/models/user_account_model', + 'js/student_account/models/user_preferences_model', + 'js/student_account/views/account_settings_fields', + 'js/student_account/views/account_settings_view' + ], function (gettext, $, _, Backbone, Logger, FieldViews, UserAccountModel, UserPreferencesModel, + AccountSettingsFieldViews, AccountSettingsView) { + + return function (fieldsData, authData, userAccountsApiUrl, userPreferencesApiUrl, accountUserId) { + + var accountSettingsElement = $('.wrapper-account-settings'); + + var userAccountModel = new UserAccountModel(); + userAccountModel.url = userAccountsApiUrl; + + var userPreferencesModel = new UserPreferencesModel(); + userPreferencesModel.url = userPreferencesApiUrl; + + var sectionsData = [ + { + title: gettext('Basic Account Information (required)'), + fields: [ + { + view: new FieldViews.ReadonlyFieldView({ + model: userAccountModel, + title: gettext('Username'), + valueAttribute: 'username', + helpMessage: gettext('The name that identifies you on the edX site. You cannot change your username.') + }) + }, + { + view: new FieldViews.TextFieldView({ + model: userAccountModel, + title: gettext('Full Name'), + valueAttribute: 'name', + helpMessage: gettext('The name that appears on your edX certificates. Other learners never see your full name.') + }) + }, + { + view: new AccountSettingsFieldViews.EmailFieldView({ + model: userAccountModel, + title: gettext('Email Address'), + valueAttribute: 'email', + helpMessage: gettext('The email address you use to sign in to edX. Communications from edX and your courses are sent to this address.') + }) + }, + { + view: new AccountSettingsFieldViews.PasswordFieldView({ + model: userAccountModel, + title: gettext('Password'), + screenReaderTitle: gettext('Reset your Password'), + valueAttribute: 'password', + emailAttribute: 'email', + linkTitle: gettext('Reset Password'), + linkHref: fieldsData.password.url, + helpMessage: gettext('When you click "Reset Password", a message will be sent to your email address. Click the link in the message to reset your password.') + }) + }, + { + view: new AccountSettingsFieldViews.LanguagePreferenceFieldView({ + model: userPreferencesModel, + title: gettext('Language'), + valueAttribute: 'pref-lang', + required: true, + refreshPageOnSave: true, + helpMessage: + gettext('The language used for the edX site. The site is currently available in a limited number of languages.'), + options: fieldsData.language.options + }) + }, + { + view: new FieldViews.DropdownFieldView({ + model: userAccountModel, + required: true, + title: gettext('Country or Region'), + valueAttribute: 'country', + options: fieldsData['country']['options'] + }) + } + ] + }, + { + title: gettext('Additional Information (optional)'), + fields: [ + { + view: new FieldViews.DropdownFieldView({ + model: userAccountModel, + title: gettext('Education Completed'), + valueAttribute: 'level_of_education', + options: fieldsData.level_of_education.options + }) + }, + { + view: new FieldViews.DropdownFieldView({ + model: userAccountModel, + title: gettext('Gender'), + valueAttribute: 'gender', + options: fieldsData.gender.options + }) + }, + { + view: new FieldViews.DropdownFieldView({ + model: userAccountModel, + title: gettext('Year of Birth'), + valueAttribute: 'year_of_birth', + options: fieldsData['year_of_birth']['options'] + }) + }, + { + view: new AccountSettingsFieldViews.LanguageProficienciesFieldView({ + model: userAccountModel, + title: gettext('Preferred Language'), + valueAttribute: 'language_proficiencies', + options: fieldsData.preferred_language.options + }) + } + ] + } + ]; + + if (_.isArray(authData.providers)) { + var accountsSectionData = { + title: gettext('Connected Accounts'), + fields: _.map(authData.providers, function(provider) { + return { + 'view': new AccountSettingsFieldViews.AuthFieldView({ + title: provider.name, + screenReaderTitle: interpolate_text( + gettext("Connect your {accountName} account"), {accountName: provider['name']} + ), + valueAttribute: 'auth-' + provider.name.toLowerCase(), + helpMessage: '', + connected: provider.connected, + connectUrl: provider.connect_url, + disconnectUrl: provider.disconnect_url + }) + }; + }) + }; + sectionsData.push(accountsSectionData); + } + + var accountSettingsView = new AccountSettingsView({ + model: userAccountModel, + accountUserId: accountUserId, + el: accountSettingsElement, + sectionsData: sectionsData + }); + + accountSettingsView.render(); + + var showLoadingError = function () { + accountSettingsView.showLoadingError(); + }; + + var showAccountFields = function () { + // Record that the account settings page was viewed. + Logger.log('edx.user.settings.viewed', { + page: "account", + visibility: null, + user_id: accountUserId + }); + + // Render the fields + accountSettingsView.renderFields(); + }; + + userAccountModel.fetch({ + success: function () { + // Fetch the user preferences model + userPreferencesModel.fetch({ + success: showAccountFields, + error: showLoadingError + }); + }, + error: showLoadingError + }); + + return { + userAccountModel: userAccountModel, + userPreferencesModel: userPreferencesModel, + accountSettingsView: accountSettingsView + }; + }; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_account/views/account_settings_fields.js b/lms/static/js/student_account/views/account_settings_fields.js new file mode 100644 index 000000000000..159ad4059b9f --- /dev/null +++ b/lms/static/js/student_account/views/account_settings_fields.js @@ -0,0 +1,183 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone', 'js/mustache', 'js/views/fields' + ], function (gettext, $, _, Backbone, RequireMustache, FieldViews) { + + var AccountSettingsFieldViews = {}; + + AccountSettingsFieldViews.EmailFieldView = FieldViews.TextFieldView.extend({ + + successMessage: function() { + return this.indicators.success + interpolate_text( + gettext( + 'We\'ve sent a confirmation message to {new_email_address}. ' + + 'Click the link in the message to update your email address.' + ), + {'new_email_address': this.fieldValue()} + ); + } + }); + + AccountSettingsFieldViews.LanguagePreferenceFieldView = FieldViews.DropdownFieldView.extend({ + + saveSucceeded: function () { + var data = { + 'language': this.modelValue() + }; + + var view = this; + $.ajax({ + type: 'POST', + url: '/i18n/setlang/', + data: data, + dataType: 'html', + success: function () { + view.showSuccessMessage(); + }, + error: function () { + view.showNotificationMessage( + view.indicators.error + + gettext( + 'You must sign out of edX and sign back in before your language ' + + 'changes take effect.' + ) + ); + } + }); + } + + }); + + AccountSettingsFieldViews.PasswordFieldView = FieldViews.LinkFieldView.extend({ + + initialize: function (options) { + this._super(options); + _.bindAll(this, 'resetPassword'); + }, + + linkClicked: function (event) { + event.preventDefault(); + this.resetPassword(event); + }, + + resetPassword: function () { + var data = {}; + data[this.options.emailAttribute] = this.model.get(this.options.emailAttribute); + + var view = this; + $.ajax({ + type: 'POST', + url: view.options.linkHref, + data: data, + success: function () { + view.showSuccessMessage(); + }, + error: function (xhr) { + view.showErrorMessage(xhr); + } + }); + }, + + successMessage: function () { + return this.indicators.success + interpolate_text( + gettext( + 'We\'ve sent a message to {email_address}. ' + + 'Click the link in the message to reset your password.' + ), + {'email_address': this.model.get(this.options.emailAttribute)} + ); + } + }); + + AccountSettingsFieldViews.LanguageProficienciesFieldView = FieldViews.DropdownFieldView.extend({ + + modelValue: function () { + var modelValue = this.model.get(this.options.valueAttribute); + if (_.isArray(modelValue) && modelValue.length > 0) { + return modelValue[0].code; + } else { + return ''; + } + }, + + saveValue: function () { + var attributes = {}, + value = this.fieldValue() ? [{'code': this.fieldValue()}] : []; + attributes[this.options.valueAttribute] = value; + this.saveAttributes(attributes); + } + + }); + + AccountSettingsFieldViews.AuthFieldView = FieldViews.LinkFieldView.extend({ + + initialize: function (options) { + this._super(options); + _.bindAll(this, 'redirect_to', 'disconnect', 'successMessage', 'inProgressMessage'); + }, + + render: function () { + this.$el.html(this.template({ + id: this.options.valueAttribute, + title: this.options.title, + screenReaderTitle: this.options.screenReaderTitle, + linkTitle: this.options.connected ? gettext('Unlink') : gettext('Link'), + linkHref: '', + message: this.helpMessage + })); + return this; + }, + + linkClicked: function (event) { + event.preventDefault(); + + this.showInProgressMessage(); + + if (this.options.connected) { + this.disconnect(); + } else { + // Direct the user to the providers site to start the authentication process. + // See python-social-auth docs for more information. + this.redirect_to(this.options.connectUrl); + } + }, + + redirect_to: function (url) { + window.location.href = url; + }, + + disconnect: function () { + var data = {}; + + // Disconnects the provider from the user's edX account. + // See python-social-auth docs for more information. + var view = this; + $.ajax({ + type: 'POST', + url: this.options.disconnectUrl, + data: data, + dataType: 'html', + success: function () { + view.options.connected = false; + view.render(); + view.showSuccessMessage(); + }, + error: function (xhr) { + view.showErrorMessage(xhr); + } + }); + }, + + inProgressMessage: function() { + return this.indicators.inProgress + (this.options.connected ? gettext('Unlinking') : gettext('Linking')); + }, + + successMessage: function() { + return this.indicators.success + gettext('Successfully unlinked.'); + } + }); + + return AccountSettingsFieldViews; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_account/views/account_settings_view.js b/lms/static/js/student_account/views/account_settings_view.js new file mode 100644 index 000000000000..15f577f05f1e --- /dev/null +++ b/lms/static/js/student_account/views/account_settings_view.js @@ -0,0 +1,41 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone' + ], function (gettext, $, _, Backbone) { + + var AccountSettingsView = Backbone.View.extend({ + + initialize: function () { + this.template = _.template($('#account_settings-tpl').text()); + _.bindAll(this, 'render', 'renderFields', 'showLoadingError'); + }, + + render: function () { + this.$el.html(this.template({ + sections: this.options.sectionsData + })); + return this; + }, + + renderFields: function () { + this.$('.ui-loading-indicator').addClass('is-hidden'); + + var view = this; + _.each(this.$('.account-settings-section-body'), function (sectionEl, index) { + _.each(view.options.sectionsData[index].fields, function (field) { + $(sectionEl).append(field.view.render().el); + }); + }); + return this; + }, + + showLoadingError: function () { + this.$('.ui-loading-indicator').addClass('is-hidden'); + this.$('.ui-loading-error').removeClass('is-hidden'); + } + }); + + return AccountSettingsView; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_profile/views/learner_profile_factory.js b/lms/static/js/student_profile/views/learner_profile_factory.js new file mode 100644 index 000000000000..4b88bc767249 --- /dev/null +++ b/lms/static/js/student_profile/views/learner_profile_factory.js @@ -0,0 +1,174 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone', 'logger', + 'js/student_account/models/user_account_model', + 'js/student_account/models/user_preferences_model', + 'js/views/fields', + 'js/student_profile/views/learner_profile_fields', + 'js/student_profile/views/learner_profile_view', + 'js/student_account/views/account_settings_fields', + 'js/views/message_banner' + ], function (gettext, $, _, Backbone, Logger, AccountSettingsModel, AccountPreferencesModel, FieldsView, + LearnerProfileFieldsView, LearnerProfileView, AccountSettingsFieldViews, MessageBannerView) { + + return function (options) { + + var learnerProfileElement = $('.wrapper-profile'); + var defaultVisibility = options.default_visibility; + var AccountPreferencesModelWithDefaults = AccountPreferencesModel.extend({ + defaults: { + account_privacy: defaultVisibility + } + }); + var accountPreferencesModel = new AccountPreferencesModelWithDefaults(); + accountPreferencesModel.url = options.preferences_api_url; + + var accountSettingsModel = new AccountSettingsModel({ + 'default_public_account_fields': options.default_public_account_fields + }); + accountSettingsModel.url = options.accounts_api_url; + + var editable = options.own_profile ? 'toggle' : 'never'; + + var messageView = new MessageBannerView({ + el: $('.message-banner') + }); + + var accountPrivacyFieldView = new LearnerProfileFieldsView.AccountPrivacyFieldView({ + model: accountPreferencesModel, + required: true, + editable: 'always', + showMessages: false, + title: gettext('edX learners can see my:'), + valueAttribute: "account_privacy", + options: [ + ['private', gettext('Limited Profile')], + ['all_users', gettext('Full Profile')] + ], + helpMessage: '', + 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", + helpMessage: "" + }); + + var sectionOneFieldViews = [ + new FieldsView.DropdownFieldView({ + model: accountSettingsModel, + screenReaderTitle: gettext('Location'), + required: true, + editable: editable, + showMessages: false, + iconName: 'fa-map-marker', + placeholderValue: '', + valueAttribute: "country", + options: options.country_options, + helpMessage: '' + }), + new AccountSettingsFieldViews.LanguageProficienciesFieldView({ + model: accountSettingsModel, + screenReaderTitle: gettext('Preferred Language'), + required: false, + editable: editable, + showMessages: false, + iconName: 'fa-comment', + placeholderValue: gettext('Add language'), + valueAttribute: "language_proficiencies", + options: options.language_options, + helpMessage: '' + }) + ]; + + var sectionTwoFieldViews = [ + new FieldsView.TextareaFieldView({ + model: accountSettingsModel, + editable: editable, + showMessages: false, + title: gettext('About me'), + placeholderValue: gettext("Tell other edX learners a little about yourself: where you live, what your interests are, why you're taking courses on edX, or what you hope to learn."), + valueAttribute: "bio", + helpMessage: '' + }) + ]; + + var learnerProfileView = new LearnerProfileView({ + el: learnerProfileElement, + ownProfile: options.own_profile, + has_preferences_access: options.has_preferences_access, + accountSettingsModel: accountSettingsModel, + preferencesModel: accountPreferencesModel, + accountPrivacyFieldView: accountPrivacyFieldView, + profileImageFieldView: profileImageFieldView, + usernameFieldView: usernameFieldView, + sectionOneFieldViews: sectionOneFieldViews, + sectionTwoFieldViews: sectionTwoFieldViews + }); + + var showLoadingError = function () { + learnerProfileView.showLoadingError(); + }; + + var getProfileVisibility = function() { + if (options.has_preferences_access) { + return accountPreferencesModel.get('account_privacy'); + } else { + return accountSettingsModel.get('profile_is_public') ? 'all_users' : 'private'; + } + }; + + var showLearnerProfileView = function() { + // Record that the profile page was viewed + Logger.log('edx.user.settings.viewed', { + page: "profile", + visibility: getProfileVisibility(), + user_id: options.profile_user_id + }); + + // Render the view for the first time + learnerProfileView.render(); + }; + + accountSettingsModel.fetch({ + success: function () { + // Fetch the preferences model if the user has access + if (options.has_preferences_access) { + accountPreferencesModel.fetch({ + success: function() { + if (accountSettingsModel.get('requires_parental_consent')) { + accountPreferencesModel.set('account_privacy', 'private'); + } + showLearnerProfileView(); + }, + error: showLoadingError + }); + } + else { + showLearnerProfileView(); + } + }, + error: showLoadingError + }); + + return { + accountSettingsModel: accountSettingsModel, + accountPreferencesModel: accountPreferencesModel, + learnerProfileView: learnerProfileView + }; + }; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_profile/views/learner_profile_fields.js b/lms/static/js/student_profile/views/learner_profile_fields.js new file mode 100644 index 000000000000..243585fc360d --- /dev/null +++ b/lms/static/js/student_profile/views/learner_profile_fields.js @@ -0,0 +1,111 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone', 'js/views/fields', 'backbone-super' + ], function (gettext, $, _, Backbone, FieldViews) { + + var LearnerProfileFieldViews = {}; + + LearnerProfileFieldViews.AccountPrivacyFieldView = FieldViews.DropdownFieldView.extend({ + + render: function () { + this._super(); + this.showNotificationMessage(); + this.updateFieldValue(); + return this; + }, + + showNotificationMessage: function () { + var accountSettingsLink = '' + gettext('Account Settings page.') + ''; + if (this.profileIsPrivate) { + this._super(interpolate_text( + gettext("You must specify your birth year before you can share your full profile. To specify your birth year, go to the {account_settings_page_link}"), + {'account_settings_page_link': accountSettingsLink} + )); + } else if (this.requiresParentalConsent) { + this._super(interpolate_text( + gettext('You must be over 13 to share a full profile. If you are over 13, make sure that you have specified a birth year on the {account_settings_page_link}'), + {'account_settings_page_link': accountSettingsLink} + )); + } + else { + this._super(''); + } + return this._super(); + }, + + updateFieldValue: function() { + if (!this.isAboveMinimumAge) { + this.$('.u-field-value select').val('private'); + this.disableField(true); + } + } + }); + + 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(''); + this.showImageChangeFailedMessage(data.jqXHR.status, data.jqXHR.responseText); + this.render(); + }, + + showImageChangeFailedMessage: function (status, responseText) { + if (_.contains([400, 404], status)) { + try { + var errors = JSON.parse(responseText); + this.showErrorMessage(errors.user_message); + } catch (error) { + this.showErrorMessage(this.errorMessage); + } + } else { + this.showErrorMessage(this.errorMessage); + } + }, + + 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); diff --git a/lms/static/js/student_profile/views/learner_profile_view.js b/lms/static/js/student_profile/views/learner_profile_view.js new file mode 100644 index 000000000000..9aa6d0e44185 --- /dev/null +++ b/lms/static/js/student_profile/views/learner_profile_view.js @@ -0,0 +1,72 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone' + ], function (gettext, $, _, Backbone) { + + var LearnerProfileView = Backbone.View.extend({ + + initialize: function () { + this.template = _.template($('#learner_profile-tpl').text()); + _.bindAll(this, 'showFullProfile', 'render', 'renderFields', 'showLoadingError'); + this.listenTo(this.options.preferencesModel, "change:" + 'account_privacy', this.render); + }, + + showFullProfile: function () { + var isAboveMinimumAge = this.options.accountSettingsModel.isAboveMinimumAge(); + if (this.options.ownProfile) { + return isAboveMinimumAge && this.options.preferencesModel.get('account_privacy') === 'all_users'; + } else { + return this.options.accountSettingsModel.get('profile_is_public'); + } + }, + + render: function () { + this.$el.html(this.template({ + username: this.options.accountSettingsModel.get('username'), + ownProfile: this.options.ownProfile, + showFullProfile: this.showFullProfile() + })); + this.renderFields(); + return this; + }, + + renderFields: function() { + var view = this; + + if (this.options.ownProfile) { + var fieldView = this.options.accountPrivacyFieldView, + settings = this.options.accountSettingsModel; + fieldView.profileIsPrivate = !settings.get('year_of_birth'); + fieldView.requiresParentalConsent = settings.get('requires_parental_consent'); + fieldView.isAboveMinimumAge = settings.isAboveMinimumAge(); + fieldView.undelegateEvents(); + this.$('.wrapper-profile-field-account-privacy').append(fieldView.render().el); + fieldView.delegateEvents(); + } + + this.$('.profile-section-one-fields').append(this.options.usernameFieldView.render().el); + + var imageView = this.options.profileImageFieldView; + this.$('.profile-image-field').append(imageView.render().el); + + if (this.showFullProfile()) { + _.each(this.options.sectionOneFieldViews, function (fieldView) { + view.$('.profile-section-one-fields').append(fieldView.render().el); + }); + + _.each(this.options.sectionTwoFieldViews, function (fieldView) { + view.$('.profile-section-two-fields').append(fieldView.render().el); + }); + } + }, + + showLoadingError: function () { + this.$('.ui-loading-indicator').addClass('is-hidden'); + this.$('.ui-loading-error').removeClass('is-hidden'); + } + }); + + return LearnerProfileView; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/vendor/backbone-super.js b/lms/static/js/vendor/backbone-super.js new file mode 100644 index 000000000000..49f95842a577 --- /dev/null +++ b/lms/static/js/vendor/backbone-super.js @@ -0,0 +1,114 @@ +// https://github.com/lukasolson/backbone-super +// MIT License + +// This is a plugin, constructed from parts of Backbone.js and John Resig's inheritance script. +// (See http://backbonejs.org, http://ejohn.org/blog/simple-javascript-inheritance/) +// No credit goes to me as I did absolutely nothing except patch these two together. +(function(root, factory) { + + // Set up Backbone appropriately for the environment. Start with AMD. + if (typeof define === 'function' && define.amd) { + define(['underscore', 'backbone'], function(_, Backbone) { + // Export global even in AMD case in case this script is loaded with + // others that may still expect a global Backbone. + factory( _, Backbone); + }); + + // Next for Node.js or CommonJS. + } else if (typeof exports !== 'undefined' && typeof require === 'function') { + var _ = require('underscore'), + Backbone = require('backbone'); + factory(_, Backbone); + + // Finally, as a browser global. + } else { + factory(root._, root.Backbone); + } + +}(this, function factory(_, Backbone) { + Backbone.Model.extend = Backbone.Collection.extend = Backbone.Router.extend = Backbone.View.extend = function(protoProps, classProps) { + var child = inherits(this, protoProps, classProps); + child.extend = this.extend; + return child; + }; + var unImplementedSuper = function(method){throw "Super does not implement this method: " + method;}; + + var fnTest = /\b_super\b/; + + var makeWrapper = function(parentProto, name, fn) { + var wrapper = function() { + var tmp = this._super; + + // Add a new ._super() method that is the same method + // but on the super-class + this._super = parentProto[name] || unImplementedSuper(name); + + // The method only need to be bound temporarily, so we + // remove it when we're done executing + var ret; + try { + ret = fn.apply(this, arguments); + } finally { + this._super = tmp; + } + return ret; + }; + + //we must move properties from old function to new + for (var prop in fn) { + wrapper[prop] = fn[prop]; + delete fn[prop]; + } + + return wrapper; + }; + + var ctor = function(){}, inherits = function(parent, protoProps, staticProps) { + var child, parentProto = parent.prototype; + + // The constructor function for the new subclass is either defined by you + // (the "constructor" property in your `extend` definition), or defaulted + // by us to simply call the parent's constructor. + if (protoProps && protoProps.hasOwnProperty('constructor')) { + child = protoProps.constructor; + } else { + child = function(){ return parent.apply(this, arguments); }; + } + + // Inherit class (static) properties from parent. + _.extend(child, parent, staticProps); + + // Set the prototype chain to inherit from `parent`, without calling + // `parent`'s constructor function. + ctor.prototype = parentProto; + child.prototype = new ctor(); + + // Add prototype properties (instance properties) to the subclass, + // if supplied. + if (protoProps) { + _.extend(child.prototype, protoProps); + + // Copy the properties over onto the new prototype + for (var name in protoProps) { + // Check if we're overwriting an existing function + if (typeof protoProps[name] == "function" && fnTest.test(protoProps[name])) { + child.prototype[name] = makeWrapper(parentProto, name, protoProps[name]); + } + } + } + + // Add static properties to the constructor function, if supplied. + if (staticProps) _.extend(child, staticProps); + + // Correctly set child's `prototype.constructor`. + child.prototype.constructor = child; + + // Set a convenience property in case the parent's prototype is needed later. + child.__super__ = parentProto; + + return child; + }; + + return inherits; +})); + diff --git a/lms/static/js/views/fields.js b/lms/static/js/views/fields.js new file mode 100644 index 000000000000..33456b9d63c7 --- /dev/null +++ b/lms/static/js/views/fields.js @@ -0,0 +1,733 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone', 'js/mustache', 'backbone-super' + ], function (gettext, $, _, Backbone, RequireMustache) { + + var Mustache = window.Mustache || RequireMustache; + + var messageRevertDelay = 4000; + var FieldViews = {}; + + FieldViews.FieldView = Backbone.View.extend({ + + fieldType: 'generic', + + className: function () { + return 'u-field' + ' u-field-' + this.fieldType + ' u-field-' + this.options.valueAttribute; + }, + + tagName: 'div', + + indicators: { + 'canEdit': '' + gettext("Editable") + '', + 'error': '' + gettext("Error") + '', + 'validationError': '' + gettext("Validation Error") + '', + 'inProgress': '' + gettext("In Progress") + '', + 'success': '' + gettext("Success") + '', + 'plus': '' + gettext("Placeholder")+ '' + }, + + messages: { + 'canEdit': '', + 'error': gettext('An error occurred. Please try again.'), + 'validationError': '', + 'inProgress': gettext('Saving'), + 'success': gettext('Your changes have been saved.') + }, + + initialize: function () { + + this.template = _.template($(this.templateSelector).text()); + + this.helpMessage = this.options.helpMessage || ''; + this.showMessages = _.isUndefined(this.options.showMessages) ? true : this.options.showMessages; + + _.bindAll(this, 'modelValue', 'modelValueIsSet', 'showNotificationMessage','getNotificationMessage', + 'getMessage', 'title', 'showHelpMessage', 'showInProgressMessage', 'showSuccessMessage', + 'showErrorMessage' + ); + }, + + modelValue: function () { + return this.model.get(this.options.valueAttribute); + }, + + modelValueIsSet: function() { + return (this.modelValue() === true); + }, + + title: function (text) { + return this.$('.u-field-title').html(text); + }, + + getMessage: function(message_status) { + if ((message_status + 'Message') in this) { + return this[message_status + 'Message'].call(this); + } else if (this.showMessages) { + return this.indicators[message_status] + this.messages[message_status]; + } + return this.indicators[message_status]; + }, + + showHelpMessage: function (message) { + if (_.isUndefined(message) || _.isNull(message)) { + message = this.helpMessage; + } + this.$('.u-field-message-notification').html(''); + this.$('.u-field-message-help').html(message); + }, + + getNotificationMessage: function() { + return this.$('.u-field-message-notification').html(); + }, + + showNotificationMessage: function(message) { + this.$('.u-field-message-help').html(''); + this.$('.u-field-message-notification').html(message); + }, + + showCanEditMessage: function(show) { + if (!_.isUndefined(show) && show) { + this.showNotificationMessage(this.getMessage('canEdit')); + } else { + this.showNotificationMessage(''); + } + }, + + showInProgressMessage: function () { + this.showNotificationMessage(this.getMessage('inProgress')); + }, + + showSuccessMessage: function () { + var successMessage = this.getMessage('success'); + this.showNotificationMessage(successMessage); + + if (this.options.refreshPageOnSave) { + document.location.reload(); + } + + var view = this; + + var context = Date.now(); + this.lastSuccessMessageContext = context; + + setTimeout(function () { + if ((context === view.lastSuccessMessageContext) && (view.getNotificationMessage() === successMessage)) { + view.showHelpMessage(); + } + }, messageRevertDelay); + }, + + showErrorMessage: function (xhr) { + if (xhr.status === 400) { + try { + var errors = JSON.parse(xhr.responseText), + validationErrorMessage = Mustache.escapeHtml( + errors.field_errors[this.options.valueAttribute].user_message + ), + message = this.indicators.validationError + validationErrorMessage; + this.showNotificationMessage(message); + } catch (error) { + this.showNotificationMessage(this.getMessage('error')); + } + } else { + this.showNotificationMessage(this.getMessage('error')); + } + } + }); + + FieldViews.EditableFieldView = FieldViews.FieldView.extend({ + + initialize: function (options) { + _.bindAll(this, 'saveAttributes', 'saveSucceeded', 'showDisplayMode', 'showEditMode', + 'startEditing', 'finishEditing' + ); + this._super(options); + + this.editable = _.isUndefined(this.options.editable) ? 'always': this.options.editable; + this.$el.addClass('editable-' + this.editable); + + if (this.editable === 'always') { + this.showEditMode(false); + } else { + this.showDisplayMode(false); + } + }, + + saveAttributes: function (attributes, options) { + var view = this; + var defaultOptions = { + contentType: 'application/merge-patch+json', + patch: true, + wait: true, + success: function () { + view.saveSucceeded(); + }, + error: function (model, xhr) { + view.showErrorMessage(xhr); + } + }; + this.showInProgressMessage(); + this.model.save(attributes, _.extend(defaultOptions, options)); + }, + + saveSucceeded: function () { + this.showSuccessMessage(); + }, + + showDisplayMode: function(render) { + this.mode = 'display'; + if (render) { this.render(); } + + this.$el.removeClass('mode-edit'); + + this.$el.toggleClass('mode-hidden', (this.editable === 'never' && !this.modelValueIsSet())); + this.$el.toggleClass('mode-placeholder', (this.editable === 'toggle' && !this.modelValueIsSet())); + this.$el.toggleClass('mode-display', (this.modelValueIsSet())); + }, + + showEditMode: function(render) { + this.mode = 'edit'; + if (render) { this.render(); } + + this.$el.removeClass('mode-hidden'); + this.$el.removeClass('mode-placeholder'); + this.$el.removeClass('mode-display'); + + this.$el.addClass('mode-edit'); + }, + + startEditing: function () { + if (this.editable === 'toggle' && this.mode !== 'edit') { + this.showEditMode(true); + } + }, + + finishEditing: function() { + if (this.fieldValue() !== this.modelValue()) { + this.saveValue(); + } else { + if (this.editable === 'always') { + this.showEditMode(true); + } else { + this.showDisplayMode(true); + } + } + } + }); + + FieldViews.ReadonlyFieldView = FieldViews.FieldView.extend({ + + fieldType: 'readonly', + + templateSelector: '#field_readonly-tpl', + + initialize: function (options) { + this._super(options); + _.bindAll(this, 'render', 'fieldValue', 'updateValueInField'); + this.listenTo(this.model, "change:" + this.options.valueAttribute, this.updateValueInField); + }, + + render: function () { + this.$el.html(this.template({ + id: this.options.valueAttribute, + title: this.options.title, + value: this.modelValue(), + message: this.helpMessage + })); + this.delegateEvents(); + return this; + }, + + fieldValue: function () { + return this.$('.u-field-value input').val(); + }, + + updateValueInField: function () { + this.$('.u-field-value input').val(Mustache.escapeHtml(this.modelValue())); + } + }); + + FieldViews.TextFieldView = FieldViews.EditableFieldView.extend({ + + fieldType: 'text', + + templateSelector: '#field_text-tpl', + + events: { + 'change input': 'saveValue' + }, + + initialize: function (options) { + this._super(options); + _.bindAll(this, 'render', 'fieldValue', 'updateValueInField', 'saveValue'); + this.listenTo(this.model, "change:" + this.options.valueAttribute, this.updateValueInField); + }, + + render: function () { + this.$el.html(this.template({ + id: this.options.valueAttribute, + title: this.options.title, + value: this.modelValue(), + message: this.helpMessage + })); + this.delegateEvents(); + return this; + }, + + fieldValue: function () { + return this.$('.u-field-value input').val(); + }, + + updateValueInField: function () { + var value = (_.isUndefined(this.modelValue()) || _.isNull(this.modelValue())) ? '' : this.modelValue(); + this.$('.u-field-value input').val(Mustache.escapeHtml(value)); + }, + + saveValue: function () { + var attributes = {}; + attributes[this.options.valueAttribute] = this.fieldValue(); + this.saveAttributes(attributes); + } + }); + + FieldViews.DropdownFieldView = FieldViews.EditableFieldView.extend({ + + fieldType: 'dropdown', + + templateSelector: '#field_dropdown-tpl', + + events: { + 'click': 'startEditing', + 'change select': 'finishEditing', + 'focusout select': 'finishEditing' + }, + + initialize: function (options) { + _.bindAll(this, 'render', 'optionForValue', 'fieldValue', 'displayValue', 'updateValueInField', 'saveValue'); + this._super(options); + + this.listenTo(this.model, "change:" + this.options.valueAttribute, this.updateValueInField); + }, + + render: function () { + this.$el.html(this.template({ + id: this.options.valueAttribute, + mode: this.mode, + title: this.options.title, + screenReaderTitle: this.options.screenReaderTitle || this.options.title, + iconName: this.options.iconName, + showBlankOption: (!this.options.required || !this.modelValueIsSet()), + selectOptions: this.options.options, + message: this.helpMessage + })); + this.delegateEvents(); + this.updateValueInField(); + + if (this.editable === 'toggle') { + this.showCanEditMessage(this.mode === 'display'); + } + return this; + }, + + modelValueIsSet: function() { + var value = this.modelValue(); + if (_.isUndefined(value) || _.isNull(value) || value === '') { + return false; + } else { + return !(_.isUndefined(this.optionForValue(value))); + } + }, + + optionForValue: function(value) { + return _.find(this.options.options, function(option) { return option[0] === value; }); + }, + + fieldValue: function () { + return this.$('.u-field-value select').val(); + }, + + displayValue: function (value) { + if (value) { + var option = this.optionForValue(value); + return (option ? option[1] : ''); + } else { + return ''; + } + }, + + updateValueInField: function () { + if (this.mode === 'display') { + var value = this.displayValue(this.modelValue() || ''); + if (this.modelValueIsSet() === false) { + value = this.options.placeholderValue || ''; + } + this.$('.u-field-value').attr('aria-label', this.options.title); + this.$('.u-field-value-readonly').html(Mustache.escapeHtml(value)); + this.showDisplayMode(false); + } else { + this.$('.u-field-value select').val(this.modelValue() || ''); + } + }, + + saveValue: function () { + var attributes = {}; + attributes[this.options.valueAttribute] = this.fieldValue(); + this.saveAttributes(attributes); + }, + + showEditMode: function(render) { + this._super(render); + if (this.editable === 'toggle') { + this.$('.u-field-value select').focus(); + } + }, + + saveSucceeded: function() { + if (this.editable === 'toggle') { + this.showDisplayMode(true); + } else { + this.showEditMode(true); + } + this._super(); + }, + + disableField: function(disable) { + this.$('.u-field-value select').prop('disabled', disable); + } + }); + + FieldViews.TextareaFieldView = FieldViews.EditableFieldView.extend({ + + fieldType: 'textarea', + + templateSelector: '#field_textarea-tpl', + + events: { + 'click .wrapper-u-field': 'startEditing', + 'click .u-field-placeholder': 'startEditing', + 'focusout textarea': 'finishEditing', + 'change textarea': 'adjustTextareaHeight', + 'keyup textarea': 'adjustTextareaHeight', + 'keydown textarea': 'adjustTextareaHeight', + 'paste textarea': 'adjustTextareaHeight', + 'cut textarea': 'adjustTextareaHeight' + }, + + initialize: function (options) { + _.bindAll(this, 'render', 'adjustTextareaHeight', 'fieldValue', 'saveValue', 'updateView'); + this._super(options); + this.listenTo(this.model, "change:" + this.options.valueAttribute, this.updateView); + }, + + render: function () { + var value = this.modelValue(); + if (this.mode === 'display') { + value = value || this.options.placeholderValue; + } + this.$el.html(this.template({ + id: this.options.valueAttribute, + screenReaderTitle: this.options.screenReaderTitle || this.options.title, + mode: this.mode, + value: value, + message: this.helpMessage, + placeholderValue: this.options.placeholderValue + })); + this.delegateEvents(); + this.title((this.modelValue() || this.mode === 'edit') ? this.options.title : this.indicators['plus'] + this.options.title); + + if (this.editable === 'toggle') { + this.showCanEditMessage(this.mode === 'display'); + } + return this; + }, + + adjustTextareaHeight: function() { + var textarea = this.$('textarea'); + textarea.css('height', 'auto').css('height', textarea.prop('scrollHeight') + 10); + }, + + modelValue: function() { + var value = this._super(); + return value ? $.trim(value) : ''; + }, + + fieldValue: function () { + return this.$('.u-field-value textarea').val(); + }, + + saveValue: function () { + var attributes = {}; + attributes[this.options.valueAttribute] = this.fieldValue(); + this.saveAttributes(attributes); + }, + + updateView: function () { + if (this.mode !== 'edit') { + this.showDisplayMode(true); + } + }, + + modelValueIsSet: function() { + return !(this.modelValue() === ''); + }, + + showEditMode: function(render) { + this._super(render); + this.adjustTextareaHeight(); + this.$('.u-field-value textarea').focus(); + }, + + saveSucceeded: function() { + this._super(); + if (this.editable === 'toggle') { + this.showDisplayMode(true); + } + } + }); + + FieldViews.LinkFieldView = FieldViews.FieldView.extend({ + + fieldType: 'link', + + templateSelector: '#field_link-tpl', + + events: { + 'click a': 'linkClicked' + }, + + initialize: function (options) { + this._super(options); + _.bindAll(this, 'render', 'linkClicked'); + }, + + render: function () { + this.$el.html(this.template({ + id: this.options.valueAttribute, + title: this.options.title, + screenReaderTitle: this.options.screenReaderTitle || this.options.title, + linkTitle: this.options.linkTitle, + linkHref: this.options.linkHref, + message: this.helpMessage + })); + this.delegateEvents(); + return this; + }, + + linkClicked: function (event) { + event.preventDefault(); + } + }); + + 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: '', + iconRemove: '', + iconProgress: '', + + 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', + 'click .upload-submit': 'clickedUploadButton' + }, + + 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.delegateEvents(); + this.updateButtonsVisibility(); + this.watchForPageUnload(); + return this; + }, + + showErrorMessage: function (message) { + return message; + }, + + 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 + }).done(function () { + view.imageChangeSucceeded(); + }).fail(function (jqXHR) { + view.showImageChangeFailedMessage(jqXHR.status, jqXHR.responseText); + }); + }, + + imageChangeSucceeded: function () { + this.render(); + }, + + imageChangeFailed: function (e, data) { + }, + + showImageChangeFailedMessage: function (status, responseText) { + }, + + fileSelected: function (e, data) { + if (_.isUndefined(data.files[0].size) || 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'); + }, + + 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 = [gettext('bytes'), gettext('KB'), gettext('MB')]; + var i = 0; + while(size >= 1024) { + size /= 1024; + ++i; + } + return size.toFixed(1)*1 + ' ' + units[i]; + } + }); + + return FieldViews; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/views/message_banner.js b/lms/static/js/views/message_banner.js new file mode 100644 index 000000000000..87e36fafa0ab --- /dev/null +++ b/lms/static/js/views/message_banner.js @@ -0,0 +1,37 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone' + ], function (gettext, $, _, Backbone) { + + var MessageBannerView = Backbone.View.extend({ + + initialize: function () { + this.template = _.template($('#message_banner-tpl').text()); + }, + + render: function () { + if (_.isUndefined(this.message) || _.isNull(this.message)) { + this.$el.html(''); + } else { + this.$el.html(this.template({ + message: this.message + })); + } + return this; + }, + + showMessage: function (message) { + this.message = message; + this.render(); + }, + + hideMessage: function () { + this.message = null; + this.render(); + } + }); + + return MessageBannerView; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index 054e828e0543..53badbe1936a 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -83,6 +83,7 @@ fixture_paths: - templates/instructor/instructor_dashboard_2 - templates/dashboard - templates/edxnotes + - templates/fields - templates/student_account - templates/student_profile - templates/verify_student diff --git a/lms/static/require-config-lms.js b/lms/static/require-config-lms.js index 3530432f0943..0e4b9ae82c57 100644 --- a/lms/static/require-config-lms.js +++ b/lms/static/require-config-lms.js @@ -47,6 +47,7 @@ "annotator_1.2.9": "js/vendor/edxnotes/annotator-full.min", "date": "js/vendor/date", "backbone": "js/vendor/backbone-min", + "backbone-super": "js/vendor/backbone-super", "underscore.string": "js/vendor/underscore.string.min", // Files needed by OVA "annotator": "js/vendor/ova/annotator-full", @@ -87,6 +88,9 @@ deps: ["underscore", "jquery"], exports: "Backbone" }, + "backbone-super": { + deps: ["backbone"], + }, "logger": { exports: "Logger" }, diff --git a/lms/static/sass/_developer.scss b/lms/static/sass/_developer.scss index b6c643b8d5f9..434add48ae5e 100644 --- a/lms/static/sass/_developer.scss +++ b/lms/static/sass/_developer.scss @@ -28,14 +28,9 @@ @include animation(rotateCW $tmg-s1 linear infinite); } -.ui-loading { +.ui-loading-base { @include animation(fadeIn $tmg-f2 linear 1); - @extend %ui-well; @extend %t-copy-base; - opacity: .6; - background-color: $white; - padding: ($baseline*1.5) $baseline; - text-align: center; .spin { @extend %anim-rotateCW; @@ -46,3 +41,12 @@ padding-left: ($baseline/4); } } + +.ui-loading { + @extend .ui-loading-base; + @extend %ui-well; + opacity: 0.6; + background-color: $white; + padding: ($baseline*1.5) $baseline; + text-align: center; +} diff --git a/lms/static/sass/application-extend2-rtl.scss.mako b/lms/static/sass/application-extend2-rtl.scss.mako index 89ba77d76c3b..a2dd1f71bcff 100644 --- a/lms/static/sass/application-extend2-rtl.scss.mako +++ b/lms/static/sass/application-extend2-rtl.scss.mako @@ -44,6 +44,8 @@ @import 'elements/system-feedback'; // base - specific views +@import "views/account-settings"; +@import "views/learner-profile"; @import 'views/verification'; @import 'views/shoppingcart'; @import 'views/login-register'; diff --git a/lms/static/sass/application-extend2.scss.mako b/lms/static/sass/application-extend2.scss.mako index e290603a4ae4..989232e8aaff 100644 --- a/lms/static/sass/application-extend2.scss.mako +++ b/lms/static/sass/application-extend2.scss.mako @@ -45,6 +45,8 @@ @import 'elements/system-feedback'; // base - specific views +@import "views/account-settings"; +@import "views/learner-profile"; @import 'views/login-register'; @import 'views/verification'; @import 'views/decoupled-verification'; diff --git a/lms/static/sass/application-rtl.scss.mako b/lms/static/sass/application-rtl.scss.mako index 2336fe85c02f..256ffb595d96 100644 --- a/lms/static/sass/application-rtl.scss.mako +++ b/lms/static/sass/application-rtl.scss.mako @@ -43,6 +43,7 @@ @import 'elements/controls'; // shared - course +@import 'shared/fields'; @import 'shared/forms'; @import 'shared/footer'; @import 'shared/header'; diff --git a/lms/static/sass/application.scss.mako b/lms/static/sass/application.scss.mako index 80a8e95a8337..43113471c896 100644 --- a/lms/static/sass/application.scss.mako +++ b/lms/static/sass/application.scss.mako @@ -43,6 +43,7 @@ @import 'elements/controls'; // shared - course +@import 'shared/fields'; @import 'shared/forms'; @import 'shared/footer'; @import 'shared/header'; diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 3544fb0eacd7..3d6930903e52 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -16,33 +16,13 @@ padding: ($baseline*2) 0 0 0; .profile-sidebar { + background: transparent; @include float(right); - @include margin-left(flex-gutter()); + margin-top: ($baseline*2); width: flex-grid(3); - background: transparent; - - .profile { - @include box-sizing(border-box); - border: 1px solid $border-color-2; - border-radius: ($baseline/4) ($baseline/4) 0 0; - width: flex-grid(12); - - .username-header { - margin-bottom: 0; - } - - .username-label { - @extend %t-title7; - @extend %t-ultrastrong; - @extend %cont-truncated; - text-align: center; - display: block; - margin: 0; - padding: ($baseline*0.75) $baseline; - color: $base-font-color; - text-transform: none; - } - } + box-shadow: 0 0 1px $shadow-l1; + border: 1px solid $border-color-2; + border-radius: 3px; .user-info { @include clearfix(); @@ -51,12 +31,8 @@ @include box-sizing(border-box); @include clearfix(); margin: 0; - border: 1px solid $border-color-2; - border-top: none; - border-radius: 0 0 ($baseline/4) ($baseline/4); padding: $baseline; width: flex-grid(12); - background: $white; li { @include clearfix(); @@ -79,19 +55,22 @@ span { display: block; + margin-bottom: ($baseline/4); } span.title { - color: $gray; - font-family: $sans-serif; - font-size: 13px; - text-transform: uppercase; + @extend %t-copy-sub1; + @extend %t-strong; a { text-transform: none; } } + span.copy { + @extend %t-copy-sub1; + } + span.data { color: $base-font-color; font-weight: 600; @@ -104,40 +83,6 @@ font-weight: inherit; } - .auth-provider { - width: flex-grid(12); - display: block; - margin-top: ($baseline/4); - - .status { - width: flex-grid(1); - display: inline-block; - color: $gray-l2; - - .fa-link { - color: $base-font-color; - } - - .copy { - @extend %text-sr; - } - } - - .provider { - width: flex-grid(9); - display: inline-block; - } - - .control { - width: flex-grid(2); - display: inline-block; - text-align: right; - - a:link, a:visited { - @extend %t-copy-sub2; - } - } - } } } @@ -147,6 +92,17 @@ line-height: 20px; } } + + .heads-up { + .title { + display: inline; + } + + .copy { + @extend %t-copy-sub2; + display: inline; + } + } } .reverify-status-list { diff --git a/lms/static/sass/shared/_fields.scss b/lms/static/sass/shared/_fields.scss new file mode 100644 index 000000000000..f277e9611403 --- /dev/null +++ b/lms/static/sass/shared/_fields.scss @@ -0,0 +1,123 @@ +// lms - shared - fields +// ==================== + + +.u-field { + padding: $baseline 0; + border-bottom: 1px solid $gray-l5; + border: 1px dashed transparent; + + &.mode-placeholder { + border: 2px dashed transparent; + border-radius: 3px; + + span { + color: $gray-l1; + } + + &:hover { + border: 2px dashed $link-color; + + span { + color: $link-color; + } + } + } + + &.editable-toggle.mode-display:hover { + background-color: $m-blue-l4; + border-radius: 3px; + + .message-can-edit { + display: inline-block; + color: $link-color; + } + + } + + &.mode-hidden { + display: none; + } + + i { + color: $gray-l2; + vertical-align:text-bottom; + @include margin-right(5px); + } + + .message-can-edit { + display: none; + } + + .message-error { + color: $alert-color; + } + + .message-validation-error { + color: $warning-color; + } + + .message-in-progress { + color: $gray-d2; + } + + .message-success { + color: $success-color; + } +} + +.u-field-readonly { + input[type="text"], + input[type="text"]:focus { + background-color: transparent; + padding: 0; + border: none; + box-shadow: none; + } +} + +.u-field-icon { + width: $baseline; + color: $gray-l2; +} + +.u-field-title { + width: flex-grid(3, 12); + display: inline-block; + color: $gray; + vertical-align: top; + margin-bottom: 0; + -webkit-font-smoothing: antialiased; + + label, span { + @include margin-left($baseline/2); + } +} + +.u-field-value { + width: flex-grid(3, 12); + display: inline-block; + vertical-align: top; + + select, input { + width: 100%; + } +} + +.u-field-message { + @extend %t-copy-sub1; + @include padding-left($baseline/2); + width: flex-grid(6, 12); + display: inline-block; + vertical-align: top; + color: $gray-l1; + + i { + @include margin-right($baseline/4); + } + + .u-field-message-help, + .u-field-message-notification { + color: $gray-l1; + } +} diff --git a/lms/static/sass/views/_account-settings.scss b/lms/static/sass/views/_account-settings.scss new file mode 100644 index 000000000000..a5d2f7b2f00b --- /dev/null +++ b/lms/static/sass/views/_account-settings.scss @@ -0,0 +1,80 @@ +// lms - application - account settings +// ==================== + +// Table of Contents +// * +Container - Account Settings +// * +Main - Header +// * +Settings Section + + +// +Container - Account Settings +.wrapper-account-settings { + @extend .container; + padding-top: ($baseline*2); + + .account-settings-container { + padding: 0; + } + + .ui-loading-indicator, + .ui-loading-error { + @extend .ui-loading-base; + // center horizontally + @include margin-left(auto); + @include margin-right(auto); + padding: ($baseline*3); + + text-align: center; + + .message-error { + color: $alert-color; + } + } +} + +// +Main - Header +.wrapper-account-settings { + + .wrapper-header { + + .header-title { + @extend %t-title4; + margin-bottom: ($baseline/2); + } + + .header-subtitle { + color: $gray-l2; + } + } +} + +// +Settings Section +.account-settings-sections { + + .section-header { + @extend %t-title6; + @extend %t-strong; + padding-bottom: ($baseline/2); + border-bottom: 1px solid $gray-l4; + } + + .section { + background-color: $white; + padding: $baseline; + margin-top: $baseline; + border: 1px solid $gray-l4; + box-shadow: 0 0 1px 1px $shadow-l2; + border-radius: 5px; + } + + a span { + color: $link-color; + } + + a span { + &:hover, &:focus { + color: $pink; + text-decoration: none !important; + } + } +} diff --git a/lms/static/sass/views/_learner-profile.scss b/lms/static/sass/views/_learner-profile.scss new file mode 100644 index 000000000000..9d7e5469c78a --- /dev/null +++ b/lms/static/sass/views/_learner-profile.scss @@ -0,0 +1,272 @@ +// lms - application - learner profile +// ==================== + +// Table of Contents +// * +Container - Learner Profile +// * +Main - Header +// * +Settings Section + +.view-profile { + $profile-image-dimension: 120px; + + .content-wrapper { + background-color: $white; + } + + .ui-loading-indicator { + @extend .ui-loading-base; + padding-bottom: $baseline; + + // center horizontally + @include margin-left(auto); + @include margin-right(auto); + 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: $profile-image-dimension; + height: $profile-image-dimension; + border-radius: ($baseline/4); + } + + .u-field-upload-button { + 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; + } + } + + .upload-button-icon, .upload-button-title { + text-align: center; + transform: translateY(35px); + -webkit-transform: translateY(35px); + display: block; + color: $white; + margin-bottom: ($baseline/4); + line-height: 1.3em; + } + + .upload-button-input { + width: $profile-image-dimension; + height: 100%; + position: absolute; + top: 0; + @include left(0); + opacity: 0; + cursor: pointer; + } + + .u-field-remove-button { + 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); + } + } + } + } + + .wrapper-profile { + min-height: 200px; + + .ui-loading-indicator { + margin-top: 100px; + } + } + + .profile-self { + .wrapper-profile-field-account-privacy { + @include clearfix(); + @include box-sizing(border-box); + margin: 0 auto 0; + padding: ($baseline*0.75) 0; + width: 100%; + background-color: $gray-l3; + + .u-field-account_privacy { + @extend .container; + border: none; + box-shadow: none; + padding: 0 ($baseline*1.5); + } + + .u-field-title { + width: auto; + color: $base-font-color; + font-weight: $font-bold; + cursor: text; + } + + .u-field-value { + width: auto; + @include margin-left($baseline/2); + } + + .u-field-message { + @include float(left); + width: 100%; + padding: 0; + color: $base-font-color; + } + } + } + + .wrapper-profile-sections { + @extend .container; + padding: 0 ($baseline*1.5); + } + + .wrapper-profile-section-one { + width: 100%; + display: inline-block; + margin-top: ($baseline*1.5); + } + + .profile-section-one-fields { + @include float(left); + width: flex-grid(4, 12); + @include margin-left($baseline*1.5); + + .u-field { + margin-bottom: ($baseline/4); + padding-top: 0; + padding-bottom: 0; + @include padding-left(3px); + } + + .u-field-username { + margin-bottom: ($baseline/2); + + input[type="text"] { + font-weight: 600; + } + + .u-field-value { + width: 350px; + @extend %t-title4; + } + } + + .u-field-title { + width: 0; + } + + .u-field-value { + width: 200px; + } + + select { + width: 100% + } + + .u-field-message { + @include float(right); + width: 20px; + margin-top: 2px; + } + } + + .wrapper-profile-section-two { + width: flex-grid(8, 12); + margin-top: ($baseline*1.5); + } + + .profile-section-two-fields { + + .u-field-textarea { + margin-bottom: ($baseline/2); + padding: ($baseline/4) ($baseline/2) ($baseline/2); + } + + .u-field-title { + font-size: 1.1em; + @extend %t-weight4; + margin-bottom: ($baseline/4); + } + + .u-field-value { + width: 100%; + white-space: pre-line; + line-height: 1.5em; + + textarea { + width: 100%; + background-color: transparent; + } + } + + .u-field-message { + @include float(right); + width: auto; + padding-top: ($baseline/4); + } + + .u-field.mode-placeholder { + padding: $baseline; + border: 2px dashed $gray-l3; + i { + font-size: 12px; + @include padding-right(5px); + vertical-align: middle; + color: $gray; + } + .u-field-title { + width: 100%; + text-align: center; + } + + .u-field-value { + text-align: center; + line-height: 1.5em; + @extend %t-copy-sub1; + color: $gray; + } + } + + .u-field.mode-placeholder:hover { + border: 2px dashed $link-color; + .u-field-title, + i { + color: $link-color; + } + } + } +} diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 04f208c48967..1c10c822fc00 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -36,10 +36,7 @@ edx.dashboard.legacy.init({ dashboard: "${reverse('dashboard')}", signInUser: "${reverse('signin_user')}", - passwordReset: "${reverse('password_reset')}", - changeEmail: "${reverse('change_email')}", changeEmailSettings: "${reverse('change_email_settings')}", - changeName: "${reverse('change_name')}", verifyToggleBannerFailedOff: "${reverse('verify_student_toggle_failed_banner_off')}", }); }); @@ -60,12 +57,6 @@
%endif - % if duplicate_provider: -
- <%include file='dashboard/_dashboard_third_party_error.html' /> -
- % endif - %if enrollment_message:
${enrollment_message} @@ -78,7 +69,8 @@

${_("Current Courses")}

- + + % if len(course_enrollment_pairs) > 0:
-
-

${_("Username")}: ${ user.username }

-
- - - - -<%include file='modal/_modal-settings-language.html' /> - - -