diff --git a/common/test/acceptance/pages/lms/dashboard.py b/common/test/acceptance/pages/lms/dashboard.py index 10958a34d9..29a20e1b05 100644 --- a/common/test/acceptance/pages/lms/dashboard.py +++ b/common/test/acceptance/pages/lms/dashboard.py @@ -202,3 +202,9 @@ class DashboardPage(PageObject): 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/fields.py b/common/test/acceptance/pages/lms/fields.py index 62124ea4f1..7512937b84 100644 --- a/common/test/acceptance/pages/lms/fields.py +++ b/common/test/acceptance/pages/lms/fields.py @@ -30,6 +30,40 @@ class FieldsMixin(object): "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. @@ -79,6 +113,23 @@ class FieldsMixin(object): "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. @@ -104,19 +155,54 @@ class FieldsMixin(object): 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'.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) - return get_selected_option_text(query) + + 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): """ 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 0000000000..a27c7b8144 --- /dev/null +++ b/common/test/acceptance/pages/lms/learner_profile.py @@ -0,0 +1,151 @@ +""" +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 + + +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 visiblie') + + 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() 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 0000000000..6fb13b689f --- /dev/null +++ b/common/test/acceptance/tests/lms/test_learner_profile.py @@ -0,0 +1,307 @@ +# -*- coding: utf-8 -*- +""" +End-to-end tests for Student's Profile Page. +""" + +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 bok_choy.web_app_test import WebAppTest + + +class LearnerProfilePageTest(WebAppTest): + """ + Tests that verify Student's Profile Page. + """ + + USER_1_NAME = 'user1' + USER_1_EMAIL = 'user1@edx.org' + USER_2_NAME = 'user2' + USER_2_EMAIL = 'user2@edx.org' + + MY_USER = 1 + OTHER_USER = 2 + + 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 setUp(self): + """ + Initialize pages. + """ + super(LearnerProfilePageTest, self).setUp() + + self.account_settings_page = AccountSettingsPage(self.browser) + self.dashboard_page = DashboardPage(self.browser) + + self.my_auto_auth_page = AutoAuthPage(self.browser, username=self.USER_1_NAME, email=self.USER_1_EMAIL).visit() + self.my_profile_page = LearnerProfilePage(self.browser, self.USER_1_NAME) + + self.other_auto_auth_page = AutoAuthPage( + self.browser, + username=self.USER_2_NAME, + email=self.USER_2_EMAIL + ).visit() + + self.other_profile_page = LearnerProfilePage(self.browser, self.USER_2_NAME) + + def authenticate_as_user(self, user): + """ + Auto authenticate a user. + """ + if user == self.MY_USER: + self.my_auto_auth_page.visit() + elif user == self.OTHER_USER: + self.other_auto_auth_page.visit() + + def set_pubilc_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_my_profile_page(self, user, privacy=None): + """ + Visits a users profile page. + """ + self.authenticate_as_user(user) + self.my_profile_page.visit() + self.my_profile_page.wait_for_page() + + if user is self.MY_USER and privacy is not None: + self.my_profile_page.privacy = privacy + + if privacy == self.PRIVACY_PUBLIC: + self.set_pubilc_profile_fields_data(self.my_profile_page) + + def visit_other_profile_page(self, user, privacy=None): + """ + Visits a users profile page. + """ + self.authenticate_as_user(user) + self.other_profile_page.visit() + self.other_profile_page.wait_for_page() + + if user is self.OTHER_USER and privacy is not None: + self.account_settings_page.visit() + self.account_settings_page.wait_for_page() + self.assertEqual(self.account_settings_page.value_for_dropdown_field('year_of_birth', '1980'), '1980') + + self.other_profile_page.visit() + self.other_profile_page.wait_for_page() + self.other_profile_page.privacy = privacy + + if privacy == self.PRIVACY_PUBLIC: + self.set_pubilc_profile_fields_data(self.other_profile_page) + + 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. + """ + self.dashboard_page.visit() + self.dashboard_page.click_username_dropdown() + self.assertTrue('My Profile' in self.dashboard_page.username_dropdown_link_text) + self.dashboard_page.click_my_profile_link() + self.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. + """ + self.visit_my_profile_page(self.MY_USER, privacy=self.PRIVACY_PRIVATE) + + self.assertTrue(self.my_profile_page.privacy_field_visible) + self.assertEqual(self.my_profile_page.visible_fields, self.PRIVATE_PROFILE_FIELDS) + + 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. + """ + self.visit_my_profile_page(self.MY_USER, privacy=self.PRIVACY_PUBLIC) + + self.assertTrue(self.my_profile_page.privacy_field_visible) + self.assertEqual(self.my_profile_page.visible_fields, self.PUBLIC_PROFILE_FIELDS) + + self.assertEqual(self.my_profile_page.editable_fields, self.PUBLIC_PROFILE_EDITABLE_FIELDS) + + def test_fields_on_others_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 others private profile page. + Then I shouldn't see the profile visibility selector dropdown. + Then I see some of the profile fields are shown. + """ + self.visit_other_profile_page(self.OTHER_USER, privacy=self.PRIVACY_PRIVATE) + self.visit_other_profile_page(self.MY_USER) + + self.assertFalse(self.other_profile_page.privacy_field_visible) + self.assertEqual(self.other_profile_page.visible_fields, self.PRIVATE_PROFILE_FIELDS) + + def test_fields_on_others_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 others 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. + """ + self.visit_other_profile_page(self.OTHER_USER, privacy=self.PRIVACY_PUBLIC) + self.visit_other_profile_page(self.MY_USER) + + self.other_profile_page.wait_for_public_fields() + self.assertFalse(self.other_profile_page.privacy_field_visible) + + fields_to_check = self.PUBLIC_PROFILE_FIELDS + self.assertEqual(self.other_profile_page.visible_fields, fields_to_check) + + self.assertEqual(self.my_profile_page.editable_fields, []) + + def _test_dropdown_field(self, field_id, new_value, displayed_value, mode): + """ + Test behaviour of a dropdown field. + """ + self.visit_my_profile_page(self.MY_USER, privacy=self.PRIVACY_PUBLIC) + + self.my_profile_page.value_for_dropdown_field(field_id, new_value) + self.assertEqual(self.my_profile_page.get_non_editable_mode_value(field_id), displayed_value) + self.assertTrue(self.my_profile_page.mode_for_field(field_id), mode) + + self.browser.refresh() + self.my_profile_page.wait_for_page() + + self.assertEqual(self.my_profile_page.get_non_editable_mode_value(field_id), displayed_value) + self.assertTrue(self.my_profile_page.mode_for_field(field_id), mode) + + def _test_textarea_field(self, field_id, new_value, displayed_value, mode): + """ + Test behaviour of a textarea field. + """ + self.visit_my_profile_page(self.MY_USER, privacy=self.PRIVACY_PUBLIC) + + self.my_profile_page.value_for_textarea_field(field_id, new_value) + self.assertEqual(self.my_profile_page.get_non_editable_mode_value(field_id), displayed_value) + self.assertTrue(self.my_profile_page.mode_for_field(field_id), mode) + + self.browser.refresh() + self.my_profile_page.wait_for_page() + + self.assertEqual(self.my_profile_page.get_non_editable_mode_value(field_id), displayed_value) + self.assertTrue(self.my_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. + """ + self._test_dropdown_field('country', 'Pakistan', 'Pakistan', 'display') + + self.my_profile_page.make_field_editable('country') + self.assertTrue(self.my_profile_page.mode_for_field('country'), 'edit') + + self.assertTrue(self.my_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. + """ + self._test_dropdown_field('language_proficiencies', 'Urdu', 'Urdu', 'display') + self._test_dropdown_field('language_proficiencies', '', 'Add language', 'placeholder') + + self.my_profile_page.make_field_editable('language_proficiencies') + self.assertTrue(self.my_profile_page.mode_for_field('language_proficiencies'), 'edit') + + self.assertTrue(self.my_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." + ) + + self._test_textarea_field('bio', 'Eat Sleep Code', 'Eat Sleep Code', 'display') + self._test_textarea_field('bio', '', placeholder_value, 'placeholder') + + self.my_profile_page.make_field_editable('bio') + self.assertTrue(self.my_profile_page.mode_for_field('bio'), 'edit') diff --git a/lms/djangoapps/student_profile/__init__.py b/lms/djangoapps/student_profile/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/student_profile/test/__init__.py b/lms/djangoapps/student_profile/test/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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 0000000000..e09e2c0501 --- /dev/null +++ b/lms/djangoapps/student_profile/test/test_views.py @@ -0,0 +1,69 @@ +# -*- 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']['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) diff --git a/lms/djangoapps/student_profile/views.py b/lms/djangoapps/student_profile/views.py new file mode 100644 index 0000000000..cd476583b9 --- /dev/null +++ b/lms/djangoapps/student_profile/views.py @@ -0,0 +1,71 @@ +""" Views for a student's profile information. """ + +from django.conf import settings +from django_countries import countries + +from django.core.urlresolvers import reverse +from django.contrib.auth.decorators import login_required +from django.views.decorators.http import require_http_methods + +from edxmako.shortcuts import render_to_response + + +@login_required +@require_http_methods(['GET']) +def learner_profile(request, username): + """ + Render the students profile page. + + 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: 405 if using an unsupported HTTP method + + Example usage: + GET /account/profile + """ + return render_to_response( + 'student_profile/learner_profile.html', + learner_profile_context(request.user.username, username, request.user.is_staff) + ) + + +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 + """ + language_options = [language for language in settings.ALL_LANGUAGES] + + country_options = [ + (country_code, unicode(country_name)) + for country_code, country_name in sorted( + countries.countries, key=lambda(__, name): unicode(name) + ) + ] + + context = { + 'data': { + 'default_public_account_fields': settings.ACCOUNT_VISIBILITY_CONFIGURATION['public_fields'], + 'accounts_api_url': reverse("accounts_api", kwargs={'username': profile_username}), + 'preferences_api_url': reverse('preferences_api', kwargs={'username': profile_username}), + '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': language_options, + } + } + + return context diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index b68ba7a4fa..8bebcf05dd 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -90,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' @@ -593,6 +596,8 @@ '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/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 index d927241e53..4f993b1a57 100644 --- a/lms/static/js/spec/student_account/account_settings_factory_spec.js +++ b/lms/static/js/spec/student_account/account_settings_factory_spec.js @@ -132,7 +132,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j expect(sectionsData[0].fields.length).toBe(5); var textFields = [sectionsData[0].fields[1], sectionsData[0].fields[2]]; - for (var i = 0; i < textFields ; i++) { + for (var i = 0; i < textFields.length ; i++) { var view = textFields[i].view; FieldViewsSpecHelpers.verifyTextField(view, { @@ -154,9 +154,9 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j title: view.options.title, valueAttribute: view.options.valueAttribute, helpMessage: '', - validValue: Helpers.FIELD_OPTIONS[0][0], - invalidValue1: Helpers.FIELD_OPTIONS[1][0], - invalidValue2: Helpers.FIELD_OPTIONS[2][0], + 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); } diff --git a/lms/static/js/spec/student_account/helpers.js b/lms/static/js/spec/student_account/helpers.js index a19bda2c52..f110bcfcce 100644 --- a/lms/static/js/spec/student_account/helpers.js +++ b/lms/static/js/spec/student_account/helpers.js @@ -9,11 +9,13 @@ define(['underscore'], function(_) { name: 'Student', email: 'student@edx.org', - level_of_education: '1', - gender: '2', - year_of_birth: '3', - country: '1', - language: '2' + level_of_education: '0', + gender: '0', + year_of_birth: '0', + country: '0', + language: '0', + bio: "About the student", + language_proficiencies: [{code: '1'}] }; var USER_PREFERENCES_DATA = { @@ -21,6 +23,7 @@ define(['underscore'], function(_) { }; var FIELD_OPTIONS = [ + ['0', 'Option 0'], ['1', 'Option 1'], ['2', 'Option 2'], ['3', 'Option 3'], @@ -28,9 +31,9 @@ define(['underscore'], function(_) { var expectLoadingIndicatorIsVisible = function (view, visible) { if (visible) { - expect(view.$('.ui-loading-indicator')).not.toHaveClass('is-hidden'); + expect($('.ui-loading-indicator')).not.toHaveClass('is-hidden'); } else { - expect(view.$('.ui-loading-indicator')).toHaveClass('is-hidden'); + expect($('.ui-loading-indicator')).toHaveClass('is-hidden'); } }; @@ -95,6 +98,6 @@ define(['underscore'], function(_) { expectLoadingErrorIsVisible: expectLoadingErrorIsVisible, expectElementContainsField: expectElementContainsField, expectSettingsSectionsButNotFieldsToBeRendered: expectSettingsSectionsButNotFieldsToBeRendered, - expectSettingsSectionsAndFieldsToBeRendered: expectSettingsSectionsAndFieldsToBeRendered + expectSettingsSectionsAndFieldsToBeRendered: expectSettingsSectionsAndFieldsToBeRendered, }; }); 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 0000000000..777061d69c --- /dev/null +++ b/lms/static/js/spec/student_profile/helpers.js @@ -0,0 +1,100 @@ +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) { + expect(view.model.get(view.options.valueAttribute)).toBeTruthy(); + + if (view.fieldValue()) { + expect(view.fieldValue()).toBe(view.modelValue()); + + } else if ('optionForValue' in view) { + expect($($element.find('.u-field-value')[0]).text()).toBe(view.displayValue(view.modelValue())); + + }else { + expect($($element.find('.u-field-value')[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(learnerProfileView.options.sectionOneFieldViews.length); + + _.each(sectionOneFieldElements, 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(1); + _.each(sectionOneFieldElements, function (sectionFieldElement, fieldIndex) { + expectProfileElementContainsField(sectionFieldElement, learnerProfileView.options.sectionOneFieldViews[fieldIndex]); + }); + + 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 0000000000..79807d5ea3 --- /dev/null +++ b/lms/static/js/spec/student_profile/learner_profile_factory_spec.js @@ -0,0 +1,150 @@ +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' + ], + function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews, UserAccountModel, UserPreferencesModel, + LearnerProfileView, LearnerProfileFields, LearnerProfilePage) { + 'use strict'; + + describe("edx.user.LearnerProfileFactory", function () { + + var requests; + + beforeEach(function () { + setFixtures('

Loading

'); + TemplateHelpers.installTemplate('templates/fields/field_readonly'); + TemplateHelpers.installTemplate('templates/fields/field_dropdown'); + TemplateHelpers.installTemplate('templates/fields/field_textarea'); + TemplateHelpers.installTemplate('templates/student_profile/learner_profile'); + }); + + it("show loading error when UserAccountModel fails to load", function() { + + requests = AjaxHelpers.requests(this); + + var context = LearnerProfilePage({ + 'accounts_api_url': Helpers.USER_ACCOUNTS_API_URL, + 'preferences_api_url': Helpers.USER_PREFERENCES_API_URL, + 'own_profile': true, + 'account_settings_page_url': Helpers.USER_ACCOUNTS_API_URL, + 'country_options': Helpers.FIELD_OPTIONS, + 'language_options': Helpers.FIELD_OPTIONS, + 'has_preferences_access': true + }), + learnerProfileView = context.learnerProfileView; + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(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 = LearnerProfilePage({ + 'accounts_api_url': Helpers.USER_ACCOUNTS_API_URL, + 'preferences_api_url': Helpers.USER_PREFERENCES_API_URL, + 'own_profile': true, + 'account_settings_page_url': Helpers.USER_ACCOUNTS_API_URL, + 'country_options': Helpers.FIELD_OPTIONS, + 'language_options': Helpers.FIELD_OPTIONS, + 'has_preferences_access': true + }), + learnerProfileView = context.learnerProfileView; + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView); + + var userAccountRequest = requests[0]; + expect(userAccountRequest.method).toBe('GET'); + expect(userAccountRequest.url).toBe(Helpers.USER_ACCOUNTS_API_URL); + + AjaxHelpers.respondWithJson(requests, Helpers.USER_ACCOUNTS_DATA); + 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 limited profile after models are successfully fetched", function() { + + requests = AjaxHelpers.requests(this); + + var context = LearnerProfilePage({ + 'accounts_api_url': Helpers.USER_ACCOUNTS_API_URL, + 'preferences_api_url': Helpers.USER_PREFERENCES_API_URL, + 'own_profile': true, + 'account_settings_page_url': Helpers.USER_ACCOUNTS_API_URL, + 'country_options': Helpers.FIELD_OPTIONS, + 'language_options': Helpers.FIELD_OPTIONS, + 'has_preferences_access': true + }); + + var learnerProfileView = context.learnerProfileView; + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView); + + AjaxHelpers.respondWithJson(requests, Helpers.USER_ACCOUNTS_DATA); + AjaxHelpers.respondWithJson(requests, Helpers.USER_PREFERENCES_DATA); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView) + }); + + it("renders the full profile after models are successfully fetched", function() { + + requests = AjaxHelpers.requests(this); + + var context = LearnerProfilePage({ + 'accounts_api_url': Helpers.USER_ACCOUNTS_API_URL, + 'preferences_api_url': Helpers.USER_PREFERENCES_API_URL, + 'own_profile': true, + 'account_settings_page_url': Helpers.USER_ACCOUNTS_API_URL, + 'country_options': Helpers.FIELD_OPTIONS, + 'language_options': Helpers.FIELD_OPTIONS, + 'has_preferences_access': true + }), + learnerProfileView = context.learnerProfileView; + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView); + + AjaxHelpers.respondWithJson(requests, Helpers.USER_ACCOUNTS_DATA); + AjaxHelpers.respondWithJson(requests, Helpers.USER_PREFERENCES_DATA); + + // sets the profile for full view. + context.accountPreferencesModel.set({account_privacy: 'all_users'}); + LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView, false) + }); + }); + }); 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 0000000000..484501d9b2 --- /dev/null +++ b/lms/static/js/spec/student_profile/learner_profile_view_spec.js @@ -0,0 +1,178 @@ +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' + ], + function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews, UserAccountModel, + AccountPreferencesModel, LearnerProfileFields, LearnerProfileView, AccountSettingsFieldViews) { + 'use strict'; + + describe("edx.user.LearnerProfileView", function (options) { + + var createLearnerProfileView = function (ownProfile, accountPrivacy, profileIsPublic) { + + var accountSettingsModel = new UserAccountModel(); + accountSettingsModel.set(Helpers.USER_ACCOUNTS_DATA); + accountSettingsModel.set({'profile_is_public': profileIsPublic}); + + 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 usernameFieldView = new FieldViews.ReadonlyFieldView({ + model: accountSettingsModel, + valueAttribute: "username", + helpMessage: "" + }); + + var sectionOneFieldViews = [ + usernameFieldView, + new FieldViews.DropdownFieldView({ + model: accountSettingsModel, + required: false, + editable: editable, + showMessages: false, + iconName: 'fa-map-marker', + placeholderValue: 'Add country', + 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'), + own_profile: ownProfile, + has_preferences_access: true, + accountSettingsModel: accountSettingsModel, + preferencesModel: accountPreferencesModel, + accountPrivacyFieldView: accountPrivacyFieldView, + usernameFieldView: usernameFieldView, + sectionOneFieldViews: sectionOneFieldViews, + sectionTwoFieldViews: sectionTwoFieldViews + }); + + }; + + beforeEach(function () { + setFixtures('

Loading

'); + TemplateHelpers.installTemplate('templates/fields/field_readonly'); + TemplateHelpers.installTemplate('templates/fields/field_dropdown'); + TemplateHelpers.installTemplate('templates/fields/field_textarea'); + 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 index a1f4e1dc0b..f7993cb2f6 100644 --- a/lms/static/js/spec/views/fields_helpers.js +++ b/lms/static/js/spec/views/fields_helpers.js @@ -27,7 +27,8 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j model: fieldData.model || new UserAccountModel({}), title: fieldData.title || 'Field Title', valueAttribute: fieldData.valueAttribute, - helpMessage: fieldData.helpMessage || 'I am a field message' + helpMessage: fieldData.helpMessage || 'I am a field message', + placeholderValue: fieldData.placeholderValue || 'I am a placeholder message' }; switch (fieldType) { @@ -58,8 +59,12 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j } }; - var expectTitleAndMessageToBe = function(view, expectedTitle, expectedMessage) { + var expectTitleToBe = function(view, expectedTitle) { expect(view.$('.u-field-title').text().trim()).toBe(expectedTitle); + }; + + var expectTitleAndMessageToBe = function(view, expectedTitle, expectedMessage) { + expectTitleToBe(view, expectedTitle); expect(view.$('.u-field-message').text().trim()).toBe(expectedMessage); }; @@ -125,9 +130,19 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j var request_data = {}; var url = view.model.url; - expectTitleAndMessageToBe(view, data.title, data.helpMessage); + if (data.editable === 'toggle') { + expect(view.el).toHaveClass('mode-placeholder'); + expectTitleToBe(view, data.title); + expectMessageContains(view, view.indicators['canEdit']); + view.$el.click(); + } else { + expectTitleAndMessageToBe(view, data.title, data.helpMessage); + } - view.$(data.valueElementSelector).val(data.validValue).change(); + expect(view.el).toHaveClass('mode-edit'); + expect(view.fieldValue()).not.toBe(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']); @@ -139,9 +154,14 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j AjaxHelpers.respondWithNoContent(requests); // When server returns success. - expectMessageContains(view, view.indicators['success']); + if (data.editable === 'toggle') { + expect(view.el).toHaveClass('mode-display'); + view.$el.click(); + } else { + expectMessageContains(view, view.indicators['success']); + } - view.$(data.valueElementSelector).val(data.invalidValue1).change(); + view.$(data.valueInputSelector).val(data.invalidValue1).change(); request_data[data.valueAttribute] = data.invalidValue1; AjaxHelpers.expectJsonRequest( requests, 'PATCH', url, request_data @@ -150,8 +170,9 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j // When server returns a 500 error expectMessageContains(view, view.indicators['error']); expectMessageContains(view, view.messages['error']); + expect(view.el).toHaveClass('mode-edit'); - view.$(data.valueElementSelector).val(data.invalidValue2).change(); + view.$(data.valueInputSelector).val(data.invalidValue2).change(); request_data[data.valueAttribute] = data.invalidValue2; AjaxHelpers.expectJsonRequest( requests, 'PATCH', url, request_data @@ -160,12 +181,29 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j // 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) { var selector = '.u-field-value > input'; verifyEditableField(view, _.extend({ - valueElementSelector: selector, + valueSelector: '.u-field-value', + valueInputSelector: '.u-field-value > input' }, data ), requests); } @@ -173,7 +211,8 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j var verifyDropDownField = function (view, data, requests) { var selector = '.u-field-value > select'; verifyEditableField(view, _.extend({ - valueElementSelector: selector, + valueSelector: '.u-field-value', + valueInputSelector: '.u-field-value > select' }, data ), requests); } @@ -183,6 +222,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j UserAccountModel: UserAccountModel, createFieldData: createFieldData, createErrorMessage: createErrorMessage, + expectTitleToBe: expectTitleToBe, expectTitleAndMessageToBe: expectTitleAndMessageToBe, expectMessageContains: expectMessageContains, expectAjaxRequestWithData: expectAjaxRequestWithData, diff --git a/lms/static/js/spec/views/fields_spec.js b/lms/static/js/spec/views/fields_spec.js index eee02c9886..58e4156444 100644 --- a/lms/static/js/spec/views/fields_spec.js +++ b/lms/static/js/spec/views/fields_spec.js @@ -7,7 +7,8 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j var USERNAME = 'Legolas', FULLNAME = 'Legolas Thranduil', - EMAIL = 'legolas@woodland.middlearth'; + EMAIL = 'legolas@woodland.middlearth', + BIO = "My Name is Theon Greyjoy. I'm member of House Greyjoy"; describe("edx.FieldViews", function () { @@ -19,6 +20,8 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j FieldViews.TextFieldView, FieldViews.DropdownFieldView, FieldViews.LinkFieldView, + FieldViews.TextareaFieldView + ]; beforeEach(function () { @@ -26,6 +29,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j 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(); @@ -55,7 +59,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j 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); @@ -66,7 +70,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j requests = AjaxHelpers.requests(this); - var fieldViewClass = FieldViews.FieldView; + var fieldViewClass = FieldViews.EditableFieldView; var fieldData = FieldViewsSpecHelpers.createFieldData(fieldViewClass, { title: 'Preferred Language', valueAttribute: 'language', @@ -101,7 +105,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j expect(view.$('.u-field-value input').val().trim()).toBe('bookworm'); }); - it("correctly renders, updates and persists changes to TextFieldView", function() { + it("correctly renders, updates and persists changes to TextFieldView when editable == always", function() { requests = AjaxHelpers.requests(this); @@ -123,7 +127,28 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j }, requests); }); - it("correctly renders, updates and persists changes to DropdownFieldView", function() { + 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.expectTitleAndMessageToBe(view, fieldData.title, fieldData.helpMessage); + 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); @@ -145,6 +170,93 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j }, 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("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.expectTitleAndMessageToBe(view, fieldData.title, fieldData.helpMessage); + expect(view.el).toHaveClass('mode-hidden'); + expect(view.$('.u-field-value').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').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.expectTitleToBe(view, fieldData.title); + FieldViewsSpecHelpers.expectMessageContains(view, view.indicators['canEdit']); + expect(view.el).toHaveClass('mode-placeholder'); + expect(view.$('.u-field-value').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').text()).toBe(fieldData.placeholderValue); + }); + it("correctly renders LinkFieldView", function() { var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.LinkFieldView, { title: 'Title', @@ -157,5 +269,4 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j expect(view.$('.u-field-value > a').text().trim()).toBe(fieldData.linkTitle); }); }); - }); diff --git a/lms/static/js/student_account/models/user_account_model.js b/lms/static/js/student_account/models/user_account_model.js index 1eeb5dd8d7..e7afa5b194 100644 --- a/lms/static/js/student_account/models/user_account_model.js +++ b/lms/static/js/student_account/models/user_account_model.js @@ -19,7 +19,24 @@ level_of_education: null, mailing_address: "", year_of_birth: null, - language_proficiencies: [] + bio: null, + language_proficiencies: [], + requires_parental_consent: true, + default_public_account_fields: [] + }, + + parse : function(response, xhr) { + 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; } }); 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 0000000000..f524c6d4ff --- /dev/null +++ b/lms/static/js/student_profile/views/learner_profile_factory.js @@ -0,0 +1,131 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone', + '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' + + + ], function (gettext, $, _, Backbone, AccountSettingsModel, AccountPreferencesModel, FieldsView, + LearnerProfileFieldsView, LearnerProfileView, AccountSettingsFieldViews) { + + return function (options) { + + var learnerProfileElement = $('.wrapper-profile'); + + var accountPreferencesModel = new AccountPreferencesModel(); + 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 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 usernameFieldView = new FieldsView.ReadonlyFieldView({ + model: accountSettingsModel, + valueAttribute: "username", + helpMessage: "" + }); + + var sectionOneFieldViews = [ + usernameFieldView, + new FieldsView.DropdownFieldView({ + model: accountSettingsModel, + required: true, + editable: editable, + showMessages: false, + iconName: 'fa-map-marker', + placeholderValue: gettext('Add country'), + valueAttribute: "country", + options: options['country_options'], + helpMessage: '' + }), + new AccountSettingsFieldViews.LanguageProficienciesFieldView({ + model: accountSettingsModel, + 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, + own_profile: options['own_profile'], + has_preferences_access: options['has_preferences_access'], + accountSettingsModel: accountSettingsModel, + preferencesModel: accountPreferencesModel, + accountPrivacyFieldView: accountPrivacyFieldView, + usernameFieldView: usernameFieldView, + sectionOneFieldViews: sectionOneFieldViews, + sectionTwoFieldViews: sectionTwoFieldViews + }); + + var showLoadingError = function () { + learnerProfileView.showLoadingError(); + }; + + var renderLearnerProfileView = function() { + learnerProfileView.render(); + }; + + accountSettingsModel.fetch({ + success: function () { + if (options['has_preferences_access']) { + accountPreferencesModel.fetch({ + success: renderLearnerProfileView, + error: showLoadingError + }); + } + else { + renderLearnerProfileView(); + } + }, + 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 0000000000..8bdc66fd0b --- /dev/null +++ b/lms/static/js/student_profile/views/learner_profile_fields.js @@ -0,0 +1,38 @@ +;(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.message(); + return this; + }, + + message: function () { + 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': '' + gettext('Account Settings page.') + ''} + )); + } 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': '' + gettext('Account Settings page.') + ''} + )); + } + else { + this._super(''); + } + return this._super(); + } + }); + + 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 0000000000..0dea1e1066 --- /dev/null +++ b/lms/static/js/student_profile/views/learner_profile_view.js @@ -0,0 +1,71 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone' + ], function (gettext, $, _, Backbone) { + + var LearnerProfileView = Backbone.View.extend({ + + initialize: function (options) { + 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 () { + if (this.options.own_profile) { + return 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'), + profilePhoto: 'http://www.teachthought.com/wp-content/uploads/2012/07/edX-120x120.jpg', + ownProfile: this.options.own_profile, + showFullProfile: this.showFullProfile() + })); + this.renderFields(); + return this; + }, + + renderFields: function() { + var view = this; + + if (this.options.own_profile) { + var fieldView = this.options.accountPrivacyFieldView; + fieldView.profileIsPrivate = (!this.options.accountSettingsModel.get('year_of_birth')); + fieldView.requiresParentalConsent = (this.options.accountSettingsModel.get('requires_parental_consent')); + 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); + + if (this.showFullProfile()) { + _.each(this.options.sectionOneFieldViews, function (fieldView, index) { + fieldView.undelegateEvents(); + view.$('.profile-section-one-fields').append(fieldView.render().el); + fieldView.delegateEvents(); + }); + + _.each(this.options.sectionTwoFieldViews, function (fieldView, index) { + fieldView.undelegateEvents(); + view.$('.profile-section-two-fields').append(fieldView.render().el); + fieldView.delegateEvents(); + }); + } + }, + + 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/views/fields.js b/lms/static/js/views/fields.js index d643cc1edd..2b154de08a 100644 --- a/lms/static/js/views/fields.js +++ b/lms/static/js/views/fields.js @@ -10,7 +10,7 @@ var FieldViews = {}; FieldViews.FieldView = Backbone.View.extend({ - + fieldType: 'generic', className: function () { @@ -20,13 +20,16 @@ tagName: 'div', indicators: { - 'error': '', - 'validationError': '', - 'inProgress': '', - 'success': '' + 'canEdit': '', + 'error': '', + 'validationError': '', + 'inProgress': '', + 'success': '', + 'plus': '' }, messages: { + 'canEdit': '', 'error': gettext('An error occurred. Please try again.'), 'validationError': '', 'inProgress': gettext('Saving'), @@ -40,39 +43,26 @@ this.helpMessage = this.options.helpMessage || ''; this.showMessages = _.isUndefined(this.options.showMessages) ? true : this.options.showMessages; - _.bindAll(this, 'modelValue', 'saveAttributes', 'saveSucceeded', 'getMessage', - 'message', 'showHelpMessage', 'showInProgressMessage', 'showSuccessMessage', 'showErrorMessage'); + _.bindAll(this, 'modelValue', 'modelValueIsSet', 'message', 'getMessage', 'title', + 'showHelpMessage', 'showInProgressMessage', 'showSuccessMessage', 'showErrorMessage'); }, modelValue: function () { return this.model.get(this.options.valueAttribute); }, - saveAttributes: function (attributes, options) { - var view = this; - var defaultOptions = { - contentType: 'application/merge-patch+json', - patch: true, - wait: true, - success: function (model, response, options) { - view.saveSucceeded() - }, - error: function (model, xhr, options) { - view.showErrorMessage(xhr) - } - }; - this.showInProgressMessage(); - this.model.save(attributes, _.extend(defaultOptions, options)); - }, - - saveSucceeded: function () { - this.showSuccessMessage(); + modelValueIsSet: function() { + return (this.modelValue() == true); }, message: function (message) { return this.$('.u-field-message').html(message); }, + 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); @@ -82,6 +72,14 @@ return this.indicators[message_status]; }, + showCanEditMessage: function(show) { + if (!_.isUndefined(show) && show) { + this.message(this.getMessage('canEdit')); + } else { + this.message(''); + } + }, + showHelpMessage: function () { this.message(this.helpMessage); }, @@ -126,6 +124,84 @@ } }); + 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 (event) { + if (this.editable === 'toggle' && this.mode !== 'edit') { + this.showEditMode(true); + } + }, + + finishEditing: function(event) { + 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', @@ -157,7 +233,7 @@ } }); - FieldViews.TextFieldView = FieldViews.FieldView.extend({ + FieldViews.TextFieldView = FieldViews.EditableFieldView.extend({ fieldType: 'text', @@ -199,47 +275,188 @@ } }); - FieldViews.DropdownFieldView = FieldViews.FieldView.extend({ + FieldViews.DropdownFieldView = FieldViews.EditableFieldView.extend({ fieldType: 'dropdown', templateSelector: '#field_dropdown-tpl', events: { - 'change select': 'saveValue' + 'click': 'startEditing', + 'change select': 'finishEditing', + 'focusout select': 'finishEditing' }, initialize: function (options) { + _.bindAll(this, 'render', 'optionForValue', 'fieldValue', 'displayValue', 'updateValueInField', 'saveValue'); 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, + mode: this.mode, title: this.options.title, + iconName: this.options.iconName, required: this.options.required, selectOptions: this.options.options, message: this.helpMessage })); + 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 () { - var value = (_.isUndefined(this.modelValue()) || _.isNull(this.modelValue())) ? '' : this.modelValue(); - this.$('.u-field-value select').val(Mustache.escapeHtml(value)); + if (this.mode === 'display') { + var value = this.displayValue(this.modelValue() || ''); + if (this.modelValueIsSet() === false) { + value = this.options.placeholderValue || ''; + } + this.$('.u-field-value').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() { + this._super(); + if (this.editable === 'toggle') { + this.showDisplayMode(true); + } + } + }); + + 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, + mode: this.mode, + value: value, + message: this.helpMessage + })); + + 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(event) { + 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); + } } }); diff --git a/lms/static/sass/application-extend2.scss.mako b/lms/static/sass/application-extend2.scss.mako index d59ff93f44..989232e8aa 100644 --- a/lms/static/sass/application-extend2.scss.mako +++ b/lms/static/sass/application-extend2.scss.mako @@ -46,6 +46,7 @@ // 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/shared/_fields.scss b/lms/static/sass/shared/_fields.scss index 0f14daf16d..f2716343e5 100644 --- a/lms/static/sass/shared/_fields.scss +++ b/lms/static/sass/shared/_fields.scss @@ -5,6 +5,49 @@ .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; + margin-right: 5px; + } + + .message-can-edit { + display: none; + } .message-error { color: $alert-color; @@ -33,10 +76,15 @@ } } +.u-field-icon { + width: $baseline; + color: $gray-l2; +} + .u-field-title { width: flex-grid(3, 12); display: inline-block; - color: $dark-gray1; + color: $gray; vertical-align: top; margin-bottom: 0; @@ -56,12 +104,12 @@ } .u-field-message { - @extend small; + @extend %t-copy-sub1; @include padding-left($baseline/2); width: flex-grid(6, 12); display: inline-block; vertical-align: top; - color: $dark-gray1; + color: $gray-l1; i { @include margin-right($baseline/4); diff --git a/lms/static/sass/views/_learner-profile.scss b/lms/static/sass/views/_learner-profile.scss new file mode 100644 index 0000000000..b02a663b45 --- /dev/null +++ b/lms/static/sass/views/_learner-profile.scss @@ -0,0 +1,199 @@ +// lms - application - learner profile +// ==================== + +// Table of Contents +// * +Container - Learner Profile +// * +Main - Header +// * +Settings Section + +.view-profile { + $profile-photo-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); + } + + .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-photo { + @include float(left); + height: $profile-photo-dimension; + width: $profile-photo-dimension; + display: inline-block; + vertical-align: top; + } + } + + .profile-section-one-fields { + 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; + 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/fields/field_dropdown.underscore b/lms/templates/fields/field_dropdown.underscore index 365e76d52c..25505cdd22 100644 --- a/lms/templates/fields/field_dropdown.underscore +++ b/lms/templates/fields/field_dropdown.underscore @@ -1,16 +1,26 @@ - +<% if (title) { %> + +<% } %> + +<% if (iconName) { %> + +<% } %> + - + <% if (mode === 'edit') { %> + + <% } %> + <%- gettext(message) %> diff --git a/lms/templates/fields/field_textarea.underscore b/lms/templates/fields/field_textarea.underscore new file mode 100644 index 0000000000..0f2f482d98 --- /dev/null +++ b/lms/templates/fields/field_textarea.underscore @@ -0,0 +1,14 @@ +
+
+ + <%- message %> +
+ +
<% + if (mode === 'edit') { + %><% + } else { + %><%- value %><% + } + %>
+
diff --git a/lms/templates/navigation-edx.html b/lms/templates/navigation-edx.html index 837effb167..75815e31ce 100644 --- a/lms/templates/navigation-edx.html +++ b/lms/templates/navigation-edx.html @@ -83,8 +83,9 @@ site_status_msg = get_site_status_msg(course_id) diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index e2a53966ec..8528d6b723 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -91,8 +91,9 @@ site_status_msg = get_site_status_msg(course_id) diff --git a/lms/templates/student_profile/learner_profile.html b/lms/templates/student_profile/learner_profile.html new file mode 100644 index 0000000000..17be327cad --- /dev/null +++ b/lms/templates/student_profile/learner_profile.html @@ -0,0 +1,43 @@ +<%! import json %> +<%! from django.core.urlresolvers import reverse %> +<%! from django.utils.translation import ugettext as _ %> + +<%inherit file="/main.html" /> +<%namespace name='static' file='/static_content.html'/> + +<%block name="pagetitle">${_("Learner Profile")} + +<%block name="bodyclass">view-profile + +<%block name="header_extras"> + % for template_name in ["field_dropdown", "field_textarea", "field_readonly"]: + + % endfor + + % for template_name in ["learner_profile",]: + + % endfor + + +
+
+

${_("Loading")}

+
+
+<%block name="headextra"> + <%static:css group='style-course'/> + + + + diff --git a/lms/templates/student_profile/learner_profile.underscore b/lms/templates/student_profile/learner_profile.underscore new file mode 100644 index 0000000000..835dac2234 --- /dev/null +++ b/lms/templates/student_profile/learner_profile.underscore @@ -0,0 +1,29 @@ +
+
+ +
+
+
+ Profile image for <%- username %> +
+ +
+
+
+ +
+
+ <% if (!showFullProfile) { %> + <% if(ownProfile) { %> + <%- gettext("You are currently sharing a limited profile.") %> + <% } else { %> + <%- gettext("This edX learner is currently sharing a limited profile.") %> + <% } %> + <% } %> +
+
+
+
diff --git a/lms/urls.py b/lms/urls.py index 92fac5da90..61b2b0efc3 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -417,9 +417,12 @@ if settings.COURSEWARE_ENABLED: url(r'^courses/{}/lti_rest_endpoints/'.format(settings.COURSE_ID_PATTERN), 'courseware.views.get_course_lti_endpoints', name='lti_rest_endpoints'), - # Student account and profile + # Student account url(r'^account/', include('student_account.urls')), + # Student profile + url(r'^u/(?P[\w.@+-]+)$', 'student_profile.views.learner_profile', name='learner_profile'), + # Student Notes url(r'^courses/{}/edxnotes'.format(settings.COURSE_ID_PATTERN), include('edxnotes.urls'), name="edxnotes_endpoints"),