+ ## Translators: this message is displayed when a user tries to link their account with a third-party authentication provider (for example, Google or LinkedIn) with a given edX account, but their third-party account is already associated with another edX account. provider_name is the name of the third-party authentication provider, and platform_name is the name of the edX deployment.
+
${_("The {provider_name} account you selected is already linked to another {platform_name} account.").format(provider_name=duplicate_provider, platform_name=platform_name)}
+
+
+
+
diff --git a/lms/templates/header/user_dropdown.html b/lms/templates/header/user_dropdown.html
index 5cb2b5f5b840..2e7e168a6937 100644
--- a/lms/templates/header/user_dropdown.html
+++ b/lms/templates/header/user_dropdown.html
@@ -4,14 +4,13 @@
<%!
import json
-from urllib.parse import urljoin
from django.conf import settings
from django.urls import reverse
from django.utils.translation import gettext as _
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user
-from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed
+from openedx.core.djangoapps.user_api.accounts.toggles import should_redirect_to_order_history_microfrontend
from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name, get_enterprise_learner_portal
%>
@@ -24,7 +23,7 @@
enterprise_customer_portal = get_enterprise_learner_portal(request)
## Enterprises with the learner portal enabled should not show order history, as it does
## not apply to the learner's method of purchasing content.
-should_show_order_history = not enterprise_customer_portal
+should_show_order_history = should_redirect_to_order_history_microfrontend() and not enterprise_customer_portal
%>
% else:
diff --git a/lms/urls.py b/lms/urls.py
index a82d134afe7a..8b314f9f1ea2 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -669,6 +669,12 @@
include('openedx.features.calendar_sync.urls'),
),
+ # Learner profile
+ path(
+ 'u/',
+ include('openedx.features.learner_profile.urls'),
+ ),
+
# Survey Report
re_path(
fr'^survey_report/',
diff --git a/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py b/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py
index 41f91c7d1eb5..d7578f25eb87 100644
--- a/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py
+++ b/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py
@@ -44,6 +44,21 @@ def test_footer(self):
# This string comes from header.html of test-theme
self.assertContains(resp, "This is a footer for test-theme.")
+ @with_comprehensive_theme("edx.org")
+ def test_account_settings_hide_nav(self):
+ """
+ Test that theme header doesn't show marketing site links for Account Settings page.
+ """
+ self._login()
+
+ account_settings_url = reverse('account_settings')
+ response = self.client.get(account_settings_url)
+
+ # Verify that the header navigation links are hidden for the edx.org version
+ self.assertNotContains(response, "How it Works")
+ self.assertNotContains(response, "Find courses")
+ self.assertNotContains(response, "Schools & Partners")
+
@with_comprehensive_theme("test-theme")
def test_logo_image(self):
"""
diff --git a/openedx/core/djangoapps/user_api/accounts/settings_views.py b/openedx/core/djangoapps/user_api/accounts/settings_views.py
new file mode 100644
index 000000000000..002695d4e33f
--- /dev/null
+++ b/openedx/core/djangoapps/user_api/accounts/settings_views.py
@@ -0,0 +1,299 @@
+""" Views related to Account Settings. """
+
+
+import logging
+import urllib
+from datetime import datetime
+
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.http import HttpResponseRedirect
+from django.shortcuts import redirect
+from django.urls import reverse
+from django.utils.translation import gettext as _
+from django.views.decorators.http import require_http_methods
+from django_countries import countries
+
+from openedx_filters.learning.filters import AccountSettingsRenderStarted
+from common.djangoapps import third_party_auth
+from common.djangoapps.edxmako.shortcuts import render_to_response
+from common.djangoapps.student.models import UserProfile
+from common.djangoapps.third_party_auth import pipeline
+from common.djangoapps.util.date_utils import strftime_localized
+from lms.djangoapps.commerce.models import CommerceConfiguration
+from lms.djangoapps.commerce.utils import EcommerceService
+from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client
+from openedx.core.djangoapps.dark_lang.models import DarkLangConfig
+from openedx.core.djangoapps.lang_pref.api import all_languages, released_languages
+from openedx.core.djangoapps.programs.models import ProgramsApiConfig
+from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
+from openedx.core.djangoapps.user_api.accounts.toggles import (
+ should_redirect_to_account_microfrontend,
+ should_redirect_to_order_history_microfrontend
+)
+from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences
+from openedx.core.lib.edx_api_utils import get_api_data
+from openedx.core.lib.time_zone_utils import TIME_ZONE_CHOICES
+from openedx.features.enterprise_support.api import enterprise_customer_for_request
+from openedx.features.enterprise_support.utils import update_account_settings_context_for_enterprise
+
+log = logging.getLogger(__name__)
+
+
+@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
+
+ """
+ if should_redirect_to_account_microfrontend():
+ url = settings.ACCOUNT_MICROFRONTEND_URL
+
+ duplicate_provider = pipeline.get_duplicate_provider(messages.get_messages(request))
+ if duplicate_provider:
+ url = '{url}?{params}'.format(
+ url=url,
+ params=urllib.parse.urlencode({
+ 'duplicate_provider': duplicate_provider,
+ }),
+ )
+
+ return redirect(url)
+
+ context = account_settings_context(request)
+
+ account_settings_template = 'student_account/account_settings.html'
+
+ try:
+ # .. filter_implemented_name: AccountSettingsRenderStarted
+ # .. filter_type: org.openedx.learning.student.settings.render.started.v1
+ context, account_settings_template = AccountSettingsRenderStarted.run_filter(
+ context=context, template_name=account_settings_template,
+ )
+ except AccountSettingsRenderStarted.RenderInvalidAccountSettings as exc:
+ response = render_to_response(exc.account_settings_template, exc.template_context)
+ except AccountSettingsRenderStarted.RedirectToPage as exc:
+ response = HttpResponseRedirect(exc.redirect_to or reverse('dashboard'))
+ except AccountSettingsRenderStarted.RenderCustomResponse as exc:
+ response = exc.response
+ else:
+ response = render_to_response(account_settings_template, context)
+
+ return response
+
+
+def account_settings_context(request):
+ """ Context for the account settings page.
+
+ Args:
+ request: The request object.
+
+ Returns:
+ dict
+
+ """
+ user = request.user
+
+ year_of_birth_options = [(str(year), str(year)) for year in UserProfile.VALID_YEARS]
+ try:
+ user_orders = get_user_orders(user)
+ except: # pylint: disable=bare-except
+ log.exception('Error fetching order history from Otto.')
+ # Return empty order list as account settings page expect a list and
+ # it will be broken if exception raised
+ user_orders = []
+
+ beta_language = {}
+ dark_lang_config = DarkLangConfig.current()
+ if dark_lang_config.enable_beta_languages:
+ user_preferences = get_user_preferences(user)
+ pref_language = user_preferences.get('pref-lang')
+ if pref_language in dark_lang_config.beta_languages_list:
+ beta_language['code'] = pref_language
+ beta_language['name'] = settings.LANGUAGE_DICT.get(pref_language)
+
+ context = {
+ 'auth': {},
+ 'duplicate_provider': None,
+ 'nav_hidden': True,
+ 'fields': {
+ 'country': {
+ 'options': list(countries),
+ }, 'gender': {
+ 'options': [(choice[0], _(choice[1])) for choice in UserProfile.GENDER_CHOICES], # lint-amnesty, 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], # lint-amnesty, pylint: disable=translation-of-non-string
+ }, 'password': {
+ 'url': reverse('password_reset'),
+ }, 'year_of_birth': {
+ 'options': year_of_birth_options,
+ }, 'preferred_language': {
+ 'options': all_languages(),
+ }, 'time_zone': {
+ 'options': TIME_ZONE_CHOICES,
+ }
+ },
+ 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
+ 'password_reset_support_link': configuration_helpers.get_value(
+ 'PASSWORD_RESET_SUPPORT_LINK', settings.PASSWORD_RESET_SUPPORT_LINK
+ ) or settings.SUPPORT_SITE_LINK,
+ 'user_accounts_api_url': reverse("accounts_api", kwargs={'username': user.username}),
+ 'user_preferences_api_url': reverse('preferences_api', kwargs={'username': user.username}),
+ 'disable_courseware_js': True,
+ 'show_program_listing': ProgramsApiConfig.is_enabled(),
+ 'show_dashboard_tabs': True,
+ 'order_history': user_orders,
+ 'disable_order_history_tab': should_redirect_to_order_history_microfrontend(),
+ 'enable_account_deletion': configuration_helpers.get_value(
+ 'ENABLE_ACCOUNT_DELETION', settings.FEATURES.get('ENABLE_ACCOUNT_DELETION', False)
+ ),
+ 'extended_profile_fields': _get_extended_profile_fields(),
+ 'beta_language': beta_language,
+ 'enable_coppa_compliance': settings.ENABLE_COPPA_COMPLIANCE,
+ }
+
+ enterprise_customer = enterprise_customer_for_request(request)
+ update_account_settings_context_for_enterprise(context, enterprise_customer, user)
+
+ 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'] = [{
+ 'id': state.provider.provider_id,
+ '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, as long as the provider supports initiating a login.
+ 'connect_url': pipeline.get_login_url(
+ state.provider.provider_id,
+ pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS,
+ # The url the user should be directed to after the auth process has completed.
+ redirect_url=reverse('account_settings'),
+ ),
+ 'accepts_logins': state.provider.accepts_logins,
+ # 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.provider_id, state.association_id),
+ # We only want to include providers if they are either currently available to be logged
+ # in with, or if the user is already authenticated with them.
+ } for state in auth_states if state.provider.display_for_login or state.has_account]
+
+ return context
+
+
+def get_user_orders(user):
+ """Given a user, get the detail of all the orders from the Ecommerce service.
+
+ Args:
+ user (User): The user to authenticate as when requesting ecommerce.
+
+ Returns:
+ list of dict, representing orders returned by the Ecommerce service.
+ """
+ user_orders = []
+ commerce_configuration = CommerceConfiguration.current()
+ user_query = {'username': user.username}
+
+ use_cache = commerce_configuration.is_cache_enabled
+ cache_key = commerce_configuration.CACHE_KEY + '.' + str(user.id) if use_cache else None
+ commerce_user_orders = get_api_data(
+ commerce_configuration,
+ 'orders',
+ api_client=get_ecommerce_api_client(user),
+ base_api_url=get_ecommerce_api_base_url(),
+ querystring=user_query,
+ cache_key=cache_key
+ )
+
+ for order in commerce_user_orders:
+ if order['status'].lower() == 'complete':
+ date_placed = datetime.strptime(order['date_placed'], "%Y-%m-%dT%H:%M:%SZ")
+ order_data = {
+ 'number': order['number'],
+ 'price': order['total_excl_tax'],
+ 'order_date': strftime_localized(date_placed, 'SHORT_DATE'),
+ 'receipt_url': EcommerceService().get_receipt_page_url(order['number']),
+ 'lines': order['lines'],
+ }
+ user_orders.append(order_data)
+
+ return user_orders
+
+
+def _get_extended_profile_fields():
+ """Retrieve the extended profile fields from site configuration to be shown on the
+ Account Settings page
+
+ Returns:
+ A list of dicts. Each dict corresponds to a single field. The keys per field are:
+ "field_name" : name of the field stored in user_profile.meta
+ "field_label" : The label of the field.
+ "field_type" : TextField or ListField
+ "field_options": a list of tuples for options in the dropdown in case of ListField
+ """
+
+ extended_profile_fields = []
+ fields_already_showing = ['username', 'name', 'email', 'pref-lang', 'country', 'time_zone', 'level_of_education',
+ 'gender', 'year_of_birth', 'language_proficiencies', 'social_links']
+
+ field_labels_map = {
+ "first_name": _("First Name"),
+ "last_name": _("Last Name"),
+ "city": _("City"),
+ "state": _("State/Province/Region"),
+ "company": _("Company"),
+ "title": _("Title"),
+ "job_title": _("Job Title"),
+ "mailing_address": _("Mailing address"),
+ "goals": _("Tell us why you're interested in {platform_name}").format(
+ platform_name=configuration_helpers.get_value("PLATFORM_NAME", settings.PLATFORM_NAME)
+ ),
+ "profession": _("Profession"),
+ "specialty": _("Specialty")
+ }
+
+ extended_profile_field_names = configuration_helpers.get_value('extended_profile_fields', [])
+ for field_to_exclude in fields_already_showing:
+ if field_to_exclude in extended_profile_field_names:
+ extended_profile_field_names.remove(field_to_exclude)
+
+ extended_profile_field_options = configuration_helpers.get_value('EXTRA_FIELD_OPTIONS', [])
+ extended_profile_field_option_tuples = {}
+ for field in extended_profile_field_options.keys():
+ field_options = extended_profile_field_options[field]
+ extended_profile_field_option_tuples[field] = [(option.lower(), option) for option in field_options]
+
+ for field in extended_profile_field_names:
+ field_dict = {
+ "field_name": field,
+ "field_label": field_labels_map.get(field, field),
+ }
+
+ field_options = extended_profile_field_option_tuples.get(field)
+ if field_options:
+ field_dict["field_type"] = "ListField"
+ field_dict["field_options"] = field_options
+ else:
+ field_dict["field_type"] = "TextField"
+ extended_profile_fields.append(field_dict)
+
+ return extended_profile_fields
diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_filters.py b/openedx/core/djangoapps/user_api/accounts/tests/test_filters.py
new file mode 100644
index 000000000000..782549aea0b5
--- /dev/null
+++ b/openedx/core/djangoapps/user_api/accounts/tests/test_filters.py
@@ -0,0 +1,241 @@
+"""
+Test that various filters are fired for views in the certificates app.
+"""
+from django.http import HttpResponse
+from django.test import override_settings
+from django.urls import reverse
+from openedx_filters import PipelineStep
+from openedx_filters.learning.filters import AccountSettingsRenderStarted
+from rest_framework import status
+from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
+
+from openedx.core.djangolib.testing.utils import skip_unless_lms
+from common.djangoapps.student.tests.factories import UserFactory
+
+
+class TestRenderInvalidAccountSettings(PipelineStep):
+ """
+ Utility class used when getting steps for pipeline.
+ """
+
+ def run_filter(self, context, template_name): # pylint: disable=arguments-differ
+ """
+ Pipeline step that stops the course about render process.
+ """
+ raise AccountSettingsRenderStarted.RenderInvalidAccountSettings(
+ "You can't access the account settings page.",
+ account_settings_template="static_templates/server-error.html",
+ )
+
+
+class TestRedirectToPage(PipelineStep):
+ """
+ Utility class used when getting steps for pipeline.
+ """
+
+ def run_filter(self, context, template_name): # pylint: disable=arguments-differ
+ """
+ Pipeline step that redirects to dashboard before rendering the account settings page.
+
+ When raising RedirectToPage, this filter uses a redirect_to field handled by
+ the course about view that redirects to that URL.
+ """
+ raise AccountSettingsRenderStarted.RedirectToPage(
+ "You can't access this page, redirecting to dashboard.",
+ redirect_to="/courses",
+ )
+
+
+class TestRedirectToDefaultPage(PipelineStep):
+ """
+ Utility class used when getting steps for pipeline.
+ """
+
+ def run_filter(self, context, template_name): # pylint: disable=arguments-differ
+ """
+ Pipeline step that redirects to dashboard before rendering the account settings page.
+
+ When raising RedirectToPage, this filter uses a redirect_to field handled by
+ the course about view that redirects to that URL.
+ """
+ raise AccountSettingsRenderStarted.RedirectToPage(
+ "You can't access this page, redirecting to dashboard."
+ )
+
+
+class TestRenderCustomResponse(PipelineStep):
+ """
+ Utility class used when getting steps for pipeline.
+ """
+
+ def run_filter(self, context, template_name): # pylint: disable=arguments-differ
+ """Pipeline step that returns a custom response when rendering the account settings page."""
+ response = HttpResponse("Here's the text of the web page.")
+ raise AccountSettingsRenderStarted.RenderCustomResponse(
+ "You can't access this page.",
+ response=response,
+ )
+
+
+class TestAccountSettingsRender(PipelineStep):
+ """
+ Utility class used when getting steps for pipeline.
+ """
+
+ def run_filter(self, context, template_name): # pylint: disable=arguments-differ
+ """Pipeline step that returns a custom response when rendering the account settings page."""
+ template_name = 'static_templates/about.html'
+ return {
+ "context": context, "template_name": template_name,
+ }
+
+
+@skip_unless_lms
+class TestAccountSettingsFilters(SharedModuleStoreTestCase):
+ """
+ Tests for the Open edX Filters associated with the account settings proccess.
+
+ This class guarantees that the following filters are triggered during the user's account settings rendering:
+
+ - AccountSettingsRenderStarted
+ """
+ def setUp(self): # pylint: disable=arguments-differ
+ super().setUp()
+ self.user = UserFactory.create(
+ username="somestudent",
+ first_name="Student",
+ last_name="Person",
+ email="robot@robot.org",
+ is_active=True,
+ password="password",
+ )
+ self.client.login(username=self.user.username, password="password")
+ self.account_settings_url = '/account/settings'
+
+ @override_settings(
+ OPEN_EDX_FILTERS_CONFIG={
+ "org.openedx.learning.student.settings.render.started.v1": {
+ "pipeline": [
+ "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestAccountSettingsRender",
+ ],
+ "fail_silently": False,
+ },
+ },
+ )
+ def test_account_settings_render_filter_executed(self):
+ """
+ Test whether the account settings filter is triggered before the user's
+ account settings page is rendered.
+
+ Expected result:
+ - AccountSettingsRenderStarted is triggered and executes TestAccountSettingsRender
+ """
+ response = self.client.get(self.account_settings_url)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertContains(response, "This page left intentionally blank. Feel free to add your own content.")
+
+ @override_settings(
+ OPEN_EDX_FILTERS_CONFIG={
+ "org.openedx.learning.student.settings.render.started.v1": {
+ "pipeline": [
+ "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRenderInvalidAccountSettings", # pylint: disable=line-too-long
+ ],
+ "fail_silently": False,
+ },
+ },
+ PLATFORM_NAME="My site",
+ )
+ def test_account_settings_render_alternative(self):
+ """
+ Test whether the account settings filter is triggered before the user's
+ account settings page is rendered.
+
+ Expected result:
+ - AccountSettingsRenderStarted is triggered and executes TestRenderInvalidAccountSettings # pylint: disable=line-too-long
+ """
+ response = self.client.get(self.account_settings_url)
+
+ self.assertContains(response, "There has been a 500 error on the My site servers")
+
+ @override_settings(
+ OPEN_EDX_FILTERS_CONFIG={
+ "org.openedx.learning.student.settings.render.started.v1": {
+ "pipeline": [
+ "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRenderCustomResponse",
+ ],
+ "fail_silently": False,
+ },
+ },
+ )
+ def test_account_settings_render_custom_response(self):
+ """
+ Test whether the account settings filter is triggered before the user's
+ account settings page is rendered.
+
+ Expected result:
+ - AccountSettingsRenderStarted is triggered and executes TestRenderCustomResponse
+ """
+ response = self.client.get(self.account_settings_url)
+
+ self.assertEqual(response.content, b"Here's the text of the web page.")
+
+ @override_settings(
+ OPEN_EDX_FILTERS_CONFIG={
+ "org.openedx.learning.student.settings.render.started.v1": {
+ "pipeline": [
+ "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRedirectToPage",
+ ],
+ "fail_silently": False,
+ },
+ },
+ )
+ def test_account_settings_redirect_to_page(self):
+ """
+ Test whether the account settings filter is triggered before the user's
+ account settings page is rendered.
+
+ Expected result:
+ - AccountSettingsRenderStarted is triggered and executes TestRedirectToPage
+ """
+ response = self.client.get(self.account_settings_url)
+
+ self.assertEqual(response.status_code, status.HTTP_302_FOUND)
+ self.assertEqual('/courses', response.url)
+
+ @override_settings(
+ OPEN_EDX_FILTERS_CONFIG={
+ "org.openedx.learning.student.settings.render.started.v1": {
+ "pipeline": [
+ "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRedirectToDefaultPage",
+ ],
+ "fail_silently": False,
+ },
+ },
+ )
+ def test_account_settings_redirect_default(self):
+ """
+ Test whether the account settings filter is triggered before the user's
+ account settings page is rendered.
+
+ Expected result:
+ - AccountSettingsRenderStarted is triggered and executes TestRedirectToDefaultPage
+ """
+ response = self.client.get(self.account_settings_url)
+
+ self.assertEqual(response.status_code, status.HTTP_302_FOUND)
+ self.assertEqual(f"{reverse('dashboard')}", response.url)
+
+ @override_settings(OPEN_EDX_FILTERS_CONFIG={})
+ def test_account_settings_render_without_filter_config(self):
+ """
+ Test whether the course about filter is triggered before the course about
+ render without affecting its execution flow.
+
+ Expected result:
+ - AccountSettingsRenderStarted executes a noop (empty pipeline). Without any
+ modification comparing it with the effects of TestAccountSettingsRender.
+ - The view response is HTTP_200_OK.
+ """
+ response = self.client.get(self.account_settings_url)
+
+ self.assertNotContains(response, "This page left intentionally blank. Feel free to add your own content.")
diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py
new file mode 100644
index 000000000000..b49be866ff4c
--- /dev/null
+++ b/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py
@@ -0,0 +1,271 @@
+""" Tests for views related to account settings. """
+
+
+from unittest import mock
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.messages.middleware import MessageMiddleware
+from django.http import HttpRequest
+from django.test import TestCase
+from django.test.utils import override_settings
+from django.urls import reverse
+from edx_rest_api_client import exceptions
+
+from edx_toggles.toggles.testutils import override_waffle_flag
+from lms.djangoapps.commerce.models import CommerceConfiguration
+from lms.djangoapps.commerce.tests import factories
+from lms.djangoapps.commerce.tests.mocks import mock_get_orders
+from openedx.core.djangoapps.dark_lang.models import DarkLangConfig
+from openedx.core.djangoapps.lang_pref.tests.test_api import EN, LT_LT
+from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
+from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
+from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
+from openedx.core.djangoapps.user_api.accounts.settings_views import account_settings_context, get_user_orders
+from openedx.core.djangoapps.user_api.accounts.toggles import REDIRECT_TO_ACCOUNT_MICROFRONTEND
+from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory
+from openedx.core.djangolib.testing.utils import skip_unless_lms
+from openedx.features.enterprise_support.utils import get_enterprise_readonly_account_fields
+from common.djangoapps.student.tests.factories import UserFactory
+from common.djangoapps.third_party_auth.tests.testutil import ThirdPartyAuthTestMixin
+
+
+@skip_unless_lms
+class AccountSettingsViewTest(ThirdPartyAuthTestMixin, SiteMixin, ProgramsApiConfigMixin, TestCase):
+ """ Tests for the account settings view. """
+
+ USERNAME = 'student'
+ PASSWORD = 'password'
+ FIELDS = [
+ 'country',
+ 'gender',
+ 'language',
+ 'level_of_education',
+ 'password',
+ 'year_of_birth',
+ 'preferred_language',
+ 'time_zone',
+ ]
+
+ @mock.patch("django.conf.settings.MESSAGE_STORAGE", 'django.contrib.messages.storage.cookie.CookieStorage')
+ def setUp(self): # pylint: disable=arguments-differ
+ super().setUp()
+ self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD)
+ CommerceConfiguration.objects.create(cache_ttl=10, enabled=True)
+ self.client.login(username=self.USERNAME, password=self.PASSWORD)
+
+ self.request = HttpRequest()
+ self.request.user = self.user
+
+ # For these tests, two third party auth providers are enabled by default:
+ self.configure_google_provider(enabled=True, visible=True)
+ self.configure_facebook_provider(enabled=True, visible=True)
+
+ # Python-social saves auth failure notifcations in Django messages.
+ # See pipeline.get_duplicate_provider() for details.
+ self.request.COOKIES = {}
+ MessageMiddleware(get_response=lambda request: None).process_request(self.request)
+ messages.error(self.request, 'Facebook is already in use.', extra_tags='Auth facebook')
+
+ @mock.patch('openedx.features.enterprise_support.api.enterprise_customer_for_request')
+ def test_context(self, mock_enterprise_customer_for_request):
+ self.request.site = SiteFactory.create()
+ UserPreferenceFactory(user=self.user, key='pref-lang', value='lt-lt')
+ DarkLangConfig(
+ released_languages='en',
+ changed_by=self.user,
+ enabled=True,
+ beta_languages='lt-lt',
+ enable_beta_languages=True
+ ).save()
+ mock_enterprise_customer_for_request.return_value = {}
+
+ with override_settings(LANGUAGES=[EN, LT_LT], LANGUAGE_CODE='en'):
+ context = account_settings_context(self.request)
+
+ user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username})
+ assert context['user_accounts_api_url'] == user_accounts_api_url
+
+ user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username})
+ assert context['user_preferences_api_url'] == user_preferences_api_url
+
+ for attribute in self.FIELDS:
+ assert attribute in context['fields']
+
+ assert context['user_accounts_api_url'] == reverse('accounts_api', kwargs={'username': self.user.username})
+ assert context['user_preferences_api_url'] ==\
+ reverse('preferences_api', kwargs={'username': self.user.username})
+
+ assert context['duplicate_provider'] == 'facebook'
+ assert context['auth']['providers'][0]['name'] == 'Facebook'
+ assert context['auth']['providers'][1]['name'] == 'Google'
+
+ assert context['sync_learner_profile_data'] is False
+ assert context['edx_support_url'] == settings.SUPPORT_SITE_LINK
+ assert context['enterprise_name'] is None
+ assert context['enterprise_readonly_account_fields'] ==\
+ {'fields': list(get_enterprise_readonly_account_fields(self.user))}
+
+ expected_beta_language = {'code': 'lt-lt', 'name': settings.LANGUAGE_DICT.get('lt-lt')}
+ assert context['beta_language'] == expected_beta_language
+
+ @mock.patch('openedx.core.djangoapps.user_api.accounts.settings_views.enterprise_customer_for_request')
+ @mock.patch('openedx.features.enterprise_support.utils.third_party_auth.provider.Registry.get')
+ def test_context_for_enterprise_learner(
+ self, mock_get_auth_provider, mock_enterprise_customer_for_request
+ ):
+ dummy_enterprise_customer = {
+ 'uuid': 'real-ent-uuid',
+ 'name': 'Dummy Enterprise',
+ 'identity_provider': 'saml-ubc'
+ }
+ mock_enterprise_customer_for_request.return_value = dummy_enterprise_customer
+ self.request.site = SiteFactory.create()
+ mock_get_auth_provider.return_value.sync_learner_profile_data = True
+ context = account_settings_context(self.request)
+
+ user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username})
+ assert context['user_accounts_api_url'] == user_accounts_api_url
+
+ user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username})
+ assert context['user_preferences_api_url'] == user_preferences_api_url
+
+ for attribute in self.FIELDS:
+ assert attribute in context['fields']
+
+ assert context['user_accounts_api_url'] == reverse('accounts_api', kwargs={'username': self.user.username})
+ assert context['user_preferences_api_url'] ==\
+ reverse('preferences_api', kwargs={'username': self.user.username})
+
+ assert context['duplicate_provider'] == 'facebook'
+ assert context['auth']['providers'][0]['name'] == 'Facebook'
+ assert context['auth']['providers'][1]['name'] == 'Google'
+
+ assert context['sync_learner_profile_data'] == mock_get_auth_provider.return_value.sync_learner_profile_data
+ assert context['edx_support_url'] == settings.SUPPORT_SITE_LINK
+ assert context['enterprise_name'] == dummy_enterprise_customer['name']
+ assert context['enterprise_readonly_account_fields'] ==\
+ {'fields': list(get_enterprise_readonly_account_fields(self.user))}
+
+ def test_view(self):
+ """
+ Test that all fields are visible
+ """
+ view_path = reverse('account_settings')
+ response = self.client.get(path=view_path)
+
+ for attribute in self.FIELDS:
+ self.assertContains(response, attribute)
+
+ def test_header_with_programs_listing_enabled(self):
+ """
+ Verify that tabs header will be shown while program listing is enabled.
+ """
+ self.create_programs_config()
+ view_path = reverse('account_settings')
+ response = self.client.get(path=view_path)
+
+ self.assertContains(response, 'global-header')
+
+ def test_header_with_programs_listing_disabled(self):
+ """
+ Verify that nav header will be shown while program listing is disabled.
+ """
+ self.create_programs_config(enabled=False)
+ view_path = reverse('account_settings')
+ response = self.client.get(path=view_path)
+
+ self.assertContains(response, 'global-header')
+
+ def test_commerce_order_detail(self):
+ """
+ Verify that get_user_orders returns the correct order data.
+ """
+ with mock_get_orders():
+ order_detail = get_user_orders(self.user)
+
+ for i, order in enumerate(mock_get_orders.default_response['results']):
+ expected = {
+ 'number': order['number'],
+ 'price': order['total_excl_tax'],
+ 'order_date': 'Jan 01, 2016',
+ 'receipt_url': '/checkout/receipt/?order_number=' + order['number'],
+ 'lines': order['lines'],
+ }
+ assert order_detail[i] == expected
+
+ def test_commerce_order_detail_exception(self):
+ with mock_get_orders(exception=exceptions.HttpNotFoundError):
+ order_detail = get_user_orders(self.user)
+
+ assert not order_detail
+
+ def test_incomplete_order_detail(self):
+ response = {
+ 'results': [
+ factories.OrderFactory(
+ status='Incomplete',
+ lines=[
+ factories.OrderLineFactory(
+ product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory()])
+ )
+ ]
+ )
+ ]
+ }
+ with mock_get_orders(response=response):
+ order_detail = get_user_orders(self.user)
+
+ assert not order_detail
+
+ def test_order_history_with_no_product(self):
+ response = {
+ 'results': [
+ factories.OrderFactory(
+ lines=[
+ factories.OrderLineFactory(
+ product=None
+ ),
+ factories.OrderLineFactory(
+ product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory(
+ name='certificate_type',
+ value='verified'
+ )])
+ )
+ ]
+ )
+ ]
+ }
+ with mock_get_orders(response=response):
+ order_detail = get_user_orders(self.user)
+
+ assert len(order_detail) == 1
+
+ def test_redirect_view(self):
+ old_url_path = reverse('account_settings')
+ with override_waffle_flag(REDIRECT_TO_ACCOUNT_MICROFRONTEND, active=True):
+ # Test with waffle flag active and none site setting, redirects to microfrontend
+ response = self.client.get(path=old_url_path)
+ self.assertRedirects(response, settings.ACCOUNT_MICROFRONTEND_URL, fetch_redirect_response=False)
+
+ # Test with waffle flag disabled and site setting disabled, does not redirect
+ response = self.client.get(path=old_url_path)
+ for attribute in self.FIELDS:
+ self.assertContains(response, attribute)
+
+ # Test with site setting disabled, does not redirect
+ site_domain = 'othersite.example.com'
+ site = self.set_up_site(site_domain, {
+ 'SITE_NAME': site_domain,
+ 'ENABLE_ACCOUNT_MICROFRONTEND': False
+ })
+ self.client.login(username=self.USERNAME, password=self.PASSWORD)
+ response = self.client.get(path=old_url_path)
+ for attribute in self.FIELDS:
+ self.assertContains(response, attribute)
+
+ # Test with site setting enabled, redirects to microfrontend
+ site.configuration.site_values['ENABLE_ACCOUNT_MICROFRONTEND'] = True
+ site.configuration.save()
+ site.__class__.objects.clear_cache()
+ response = self.client.get(path=old_url_path)
+ self.assertRedirects(response, settings.ACCOUNT_MICROFRONTEND_URL, fetch_redirect_response=False)
diff --git a/openedx/core/djangoapps/user_api/accounts/toggles.py b/openedx/core/djangoapps/user_api/accounts/toggles.py
new file mode 100644
index 000000000000..80de4fa75692
--- /dev/null
+++ b/openedx/core/djangoapps/user_api/accounts/toggles.py
@@ -0,0 +1,44 @@
+"""
+Toggles for accounts related code.
+"""
+
+from edx_toggles.toggles import WaffleFlag
+
+from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
+
+# .. toggle_name: order_history.redirect_to_microfrontend
+# .. toggle_implementation: WaffleFlag
+# .. toggle_default: False
+# .. toggle_description: Supports staged rollout of a new micro-frontend-based implementation of the order history page.
+# .. toggle_use_cases: temporary, open_edx
+# .. toggle_creation_date: 2019-04-11
+# .. toggle_target_removal_date: 2020-12-31
+# .. toggle_warning: Also set settings.ORDER_HISTORY_MICROFRONTEND_URL and site's
+# ENABLE_ORDER_HISTORY_MICROFRONTEND.
+# .. toggle_tickets: DEPR-17
+REDIRECT_TO_ORDER_HISTORY_MICROFRONTEND = WaffleFlag('order_history.redirect_to_microfrontend', __name__)
+
+
+def should_redirect_to_order_history_microfrontend():
+ return (
+ configuration_helpers.get_value('ENABLE_ORDER_HISTORY_MICROFRONTEND') and
+ REDIRECT_TO_ORDER_HISTORY_MICROFRONTEND.is_enabled()
+ )
+
+
+# .. toggle_name: account.redirect_to_microfrontend
+# .. toggle_implementation: WaffleFlag
+# .. toggle_default: False
+# .. toggle_description: Supports staged rollout of a new micro-frontend-based implementation of the account page.
+# Its action can be overridden using site's ENABLE_ACCOUNT_MICROFRONTEND setting.
+# .. toggle_use_cases: temporary, open_edx
+# .. toggle_creation_date: 2019-04-30
+# .. toggle_target_removal_date: 2021-12-31
+# .. toggle_warning: Also set settings.ACCOUNT_MICROFRONTEND_URL.
+# .. toggle_tickets: DEPR-17
+REDIRECT_TO_ACCOUNT_MICROFRONTEND = WaffleFlag('account.redirect_to_microfrontend', __name__)
+
+
+def should_redirect_to_account_microfrontend():
+ return configuration_helpers.get_value('ENABLE_ACCOUNT_MICROFRONTEND',
+ REDIRECT_TO_ACCOUNT_MICROFRONTEND.is_enabled())
diff --git a/openedx/core/djangoapps/user_api/legacy_urls.py b/openedx/core/djangoapps/user_api/legacy_urls.py
index ad02f7f19ce8..b3f707f64b50 100644
--- a/openedx/core/djangoapps/user_api/legacy_urls.py
+++ b/openedx/core/djangoapps/user_api/legacy_urls.py
@@ -5,6 +5,7 @@
from rest_framework import routers
from . import views as user_api_views
+from .accounts.settings_views import account_settings
from .models import UserPreference
USER_API_ROUTER = routers.DefaultRouter()
@@ -12,6 +13,7 @@
USER_API_ROUTER.register(r'user_prefs', user_api_views.UserPreferenceViewSet)
urlpatterns = [
+ path('account/settings', account_settings, name='account_settings'),
path('user_api/v1/', include(USER_API_ROUTER.urls)),
re_path(
fr'^user_api/v1/preferences/(?P{UserPreference.KEY_REGEX})/users/$',
diff --git a/openedx/core/djangoapps/user_authn/cookies.py b/openedx/core/djangoapps/user_authn/cookies.py
index 036baf2125b2..24f929698fa7 100644
--- a/openedx/core/djangoapps/user_authn/cookies.py
+++ b/openedx/core/djangoapps/user_authn/cookies.py
@@ -6,7 +6,6 @@
import json
import logging
import time
-from urllib.parse import urljoin
from django.conf import settings
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
@@ -245,8 +244,8 @@ def _get_user_info_cookie_data(request, user):
# External sites will need to have fallback mechanisms to handle this case
# (most likely just hiding the links).
try:
- header_urls['account_settings'] = settings.ACCOUNT_MICROFRONTEND_URL
- header_urls['learner_profile'] = urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{user.username}')
+ header_urls['account_settings'] = reverse('account_settings')
+ header_urls['learner_profile'] = reverse('learner_profile', kwargs={'username': user.username})
except NoReverseMatch:
pass
diff --git a/openedx/core/djangoapps/user_authn/tests/test_cookies.py b/openedx/core/djangoapps/user_authn/tests/test_cookies.py
index 826ef1e1209a..a90f20f19469 100644
--- a/openedx/core/djangoapps/user_authn/tests/test_cookies.py
+++ b/openedx/core/djangoapps/user_authn/tests/test_cookies.py
@@ -4,7 +4,6 @@
from datetime import date
import json
from unittest.mock import MagicMock, patch
-from urllib.parse import urljoin
from django.conf import settings
from django.http import HttpResponse
from django.test import RequestFactory, TestCase
@@ -58,8 +57,8 @@ def _get_expected_image_urls(self):
def _get_expected_header_urls(self):
expected_header_urls = {
'logout': reverse('logout'),
- 'account_settings': settings.ACCOUNT_MICROFRONTEND_URL,
- 'learner_profile': urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{self.user.username}'),
+ 'account_settings': reverse('account_settings'),
+ 'learner_profile': reverse('learner_profile', kwargs={'username': self.user.username}),
}
block_url = retrieve_last_sitewide_block_completed(self.user)
if block_url:
diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_login.py b/openedx/core/djangoapps/user_authn/views/tests/test_login.py
index a8a591083fae..1e8a4c3ed510 100644
--- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py
+++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py
@@ -496,7 +496,7 @@ def test_login_user_info_cookie(self):
# Check that the URLs are absolute
for url in user_info["header_urls"].values():
- assert 'http://' in url
+ assert 'http://testserver/' in url
def test_logout_deletes_mktg_cookies(self):
response, _ = self._login_response(self.user_email, self.password)
diff --git a/openedx/features/learner_profile/README.rst b/openedx/features/learner_profile/README.rst
new file mode 100644
index 000000000000..0dce8e10ccdc
--- /dev/null
+++ b/openedx/features/learner_profile/README.rst
@@ -0,0 +1,8 @@
+Learner Profile
+---------------
+
+This directory contains a Django application that provides a view to render
+a profile for any Open edX learner. See `Exploring Your Dashboard and Profile`_
+for more details.
+
+.. _Exploring Your Dashboard and Profile: https://edx.readthedocs.io/projects/open-edx-learner-guide/en/latest/SFD_dashboard_profile_SectionHead.html?highlight=profile
diff --git a/openedx/features/learner_profile/__init__.py b/openedx/features/learner_profile/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/openedx/features/learner_profile/static/learner_profile/fixtures/learner_profile.html b/openedx/features/learner_profile/static/learner_profile/fixtures/learner_profile.html
new file mode 100644
index 000000000000..61c139210a20
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/fixtures/learner_profile.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An error occurred. Try loading the page again.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading
+
+
+
+
+
+
+ An error occurred. Please reload the page.
+
+
+
diff --git a/openedx/features/learner_profile/static/learner_profile/js/learner_profile_factory.js b/openedx/features/learner_profile/static/learner_profile/js/learner_profile_factory.js
new file mode 100644
index 000000000000..6419dd17703a
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/js/learner_profile_factory.js
@@ -0,0 +1,246 @@
+(function(define) {
+ 'use strict';
+
+ define([
+ 'gettext',
+ 'jquery',
+ 'underscore',
+ 'backbone',
+ 'logger',
+ 'edx-ui-toolkit/js/utils/string-utils',
+ 'edx-ui-toolkit/js/pagination/paging-collection',
+ 'js/student_account/models/user_account_model',
+ 'js/student_account/models/user_preferences_model',
+ 'js/views/fields',
+ 'learner_profile/js/views/learner_profile_fields',
+ 'learner_profile/js/views/learner_profile_view',
+ 'learner_profile/js/models/badges_model',
+ 'learner_profile/js/views/badge_list_container',
+ 'js/student_account/views/account_settings_fields',
+ 'js/views/message_banner',
+ 'string_utils'
+ ], function(gettext, $, _, Backbone, Logger, StringUtils, PagingCollection, AccountSettingsModel,
+ AccountPreferencesModel, FieldsView, LearnerProfileFieldsView, LearnerProfileView, BadgeModel,
+ BadgeListContainer, AccountSettingsFieldViews, MessageBannerView) {
+ return function(options) {
+ var $learnerProfileElement = $('.wrapper-profile');
+
+ var accountSettingsModel = new AccountSettingsModel(
+ _.extend(
+ options.account_settings_data,
+ {
+ default_public_account_fields: options.default_public_account_fields,
+ parental_consent_age_limit: options.parental_consent_age_limit,
+ enable_coppa_compliance: options.enable_coppa_compliance
+ }
+ ),
+ {parse: true}
+ );
+ var AccountPreferencesModelWithDefaults = AccountPreferencesModel.extend({
+ defaults: {
+ account_privacy: options.default_visibility
+ }
+ });
+ var accountPreferencesModel = new AccountPreferencesModelWithDefaults(options.preferences_data);
+
+ var editable = options.own_profile ? 'toggle' : 'never';
+
+ var messageView = new MessageBannerView({
+ el: $('.message-banner')
+ });
+
+ var accountPrivacyFieldView,
+ profileImageFieldView,
+ usernameFieldView,
+ nameFieldView,
+ sectionOneFieldViews,
+ sectionTwoFieldViews,
+ BadgeCollection,
+ badgeCollection,
+ badgeListContainer,
+ learnerProfileView,
+ getProfileVisibility,
+ showLearnerProfileView;
+
+ accountSettingsModel.url = options.accounts_api_url;
+ accountPreferencesModel.url = options.preferences_api_url;
+
+ accountPrivacyFieldView = new LearnerProfileFieldsView.AccountPrivacyFieldView({
+ model: accountPreferencesModel,
+ required: true,
+ editable: 'always',
+ showMessages: false,
+ title: gettext('Profile Visibility:'),
+ valueAttribute: 'account_privacy',
+ options: [
+ ['private', gettext('Limited Profile')],
+ ['all_users', gettext('Full Profile')]
+ ],
+ helpMessage: '',
+ accountSettingsPageUrl: options.account_settings_page_url,
+ persistChanges: true
+ });
+
+ 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
+ });
+
+ usernameFieldView = new FieldsView.ReadonlyFieldView({
+ model: accountSettingsModel,
+ screenReaderTitle: gettext('Username'),
+ valueAttribute: 'username',
+ helpMessage: ''
+ });
+
+ nameFieldView = new FieldsView.ReadonlyFieldView({
+ model: accountSettingsModel,
+ screenReaderTitle: gettext('Full Name'),
+ valueAttribute: 'name',
+ helpMessage: ''
+ });
+
+ sectionOneFieldViews = [
+ new LearnerProfileFieldsView.SocialLinkIconsView({
+ model: accountSettingsModel,
+ socialPlatforms: options.social_platforms,
+ ownProfile: options.own_profile
+ }),
+
+ new FieldsView.DateFieldView({
+ title: gettext('Joined'),
+ titleVisible: true,
+ model: accountSettingsModel,
+ screenReaderTitle: gettext('Joined Date'),
+ valueAttribute: 'date_joined',
+ helpMessage: '',
+ userLanguage: accountSettingsModel.get('language'),
+ userTimezone: accountPreferencesModel.get('time_zone'),
+ dateFormat: 'MMMM YYYY' // not localized, but hopefully ok.
+ }),
+
+ new FieldsView.DropdownFieldView({
+ title: gettext('Location'),
+ titleVisible: true,
+ model: accountSettingsModel,
+ screenReaderTitle: gettext('Country'),
+ required: true,
+ editable: editable,
+ showMessages: false,
+ placeholderValue: gettext('Add Country'),
+ valueAttribute: 'country',
+ options: options.country_options,
+ helpMessage: '',
+ persistChanges: true
+ }),
+
+ new AccountSettingsFieldViews.LanguageProficienciesFieldView({
+ title: gettext('Language'),
+ titleVisible: true,
+ model: accountSettingsModel,
+ screenReaderTitle: gettext('Preferred Language'),
+ required: false,
+ editable: editable,
+ showMessages: false,
+ placeholderValue: gettext('Add language'),
+ valueAttribute: 'language_proficiencies',
+ options: options.language_options,
+ helpMessage: '',
+ persistChanges: true
+ })
+ ];
+
+ sectionTwoFieldViews = [
+ new FieldsView.TextareaFieldView({
+ model: accountSettingsModel,
+ editable: editable,
+ showMessages: false,
+ title: gettext('About me'),
+ // eslint-disable-next-line max-len
+ placeholderValue: gettext("Tell other learners a little about yourself: where you live, what your interests are, why you're taking courses, or what you hope to learn."),
+ valueAttribute: 'bio',
+ helpMessage: '',
+ persistChanges: true,
+ messagePosition: 'header',
+ maxCharacters: 300
+ })
+ ];
+
+ BadgeCollection = PagingCollection.extend({
+ queryParams: {
+ currentPage: 'current_page'
+ }
+ });
+ badgeCollection = new BadgeCollection();
+ badgeCollection.url = options.badges_api_url;
+
+ badgeListContainer = new BadgeListContainer({
+ attributes: {class: 'badge-set-display'},
+ collection: badgeCollection,
+ find_courses_url: options.find_courses_url,
+ ownProfile: options.own_profile,
+ badgeMeta: {
+ badges_logo: options.badges_logo,
+ backpack_ui_img: options.backpack_ui_img,
+ badges_icon: options.badges_icon
+ }
+ });
+
+ 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,
+ nameFieldView: nameFieldView,
+ sectionOneFieldViews: sectionOneFieldViews,
+ sectionTwoFieldViews: sectionTwoFieldViews,
+ badgeListContainer: badgeListContainer,
+ platformName: options.platform_name
+ });
+
+ getProfileVisibility = function() {
+ if (options.has_preferences_access) {
+ return accountPreferencesModel.get('account_privacy');
+ } else {
+ return accountSettingsModel.get('profile_is_public') ? 'all_users' : 'private';
+ }
+ };
+
+ 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();
+ };
+
+ if (options.has_preferences_access) {
+ if (accountSettingsModel.get('requires_parental_consent')) {
+ accountPreferencesModel.set('account_privacy', 'private');
+ }
+ }
+ showLearnerProfileView();
+
+ return {
+ accountSettingsModel: accountSettingsModel,
+ accountPreferencesModel: accountPreferencesModel,
+ learnerProfileView: learnerProfileView,
+ badgeListContainer: badgeListContainer
+ };
+ };
+ });
+}).call(this, define || RequireJS.define);
diff --git a/openedx/features/learner_profile/static/learner_profile/js/models/badges_model.js b/openedx/features/learner_profile/static/learner_profile/js/models/badges_model.js
new file mode 100644
index 000000000000..42da19ef7677
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/js/models/badges_model.js
@@ -0,0 +1,8 @@
+(function(define) {
+ 'use strict';
+
+ define(['backbone'], function(Backbone) {
+ var BadgesModel = Backbone.Model.extend({});
+ return BadgesModel;
+ });
+}).call(this, define || RequireJS.define);
diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/learner_profile_factory_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/learner_profile_factory_spec.js
new file mode 100644
index 000000000000..f5b8f4bec6b5
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/js/spec/learner_profile_factory_spec.js
@@ -0,0 +1,222 @@
+define(
+ [
+ 'backbone', 'jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
+ 'common/js/spec_helpers/template_helpers',
+ 'js/spec/student_account/helpers',
+ 'learner_profile/js/spec_helpers/helpers',
+ 'js/views/fields',
+ 'js/student_account/models/user_account_model',
+ 'js/student_account/models/user_preferences_model',
+ 'learner_profile/js/views/learner_profile_view',
+ 'learner_profile/js/views/learner_profile_fields',
+ 'learner_profile/js/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 createProfilePage;
+
+ beforeEach(function() {
+ loadFixtures('learner_profile/fixtures/learner_profile.html');
+ });
+
+ afterEach(function() {
+ Backbone.history.stop();
+ });
+
+ createProfilePage = function(ownProfile, options) {
+ return new LearnerProfilePage({
+ accounts_api_url: Helpers.USER_ACCOUNTS_API_URL,
+ preferences_api_url: Helpers.USER_PREFERENCES_API_URL,
+ badges_api_url: Helpers.BADGES_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',
+ platform_name: 'edX',
+ find_courses_url: '/courses/',
+ account_settings_data: Helpers.createAccountSettingsData(options),
+ preferences_data: Helpers.createUserPreferencesData()
+ });
+ };
+
+ it('renders the full profile for a user', function() {
+ var context,
+ learnerProfileView;
+ AjaxHelpers.requests(this);
+ context = createProfilePage(true);
+ learnerProfileView = context.learnerProfileView;
+
+ // 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() {
+ var context = createProfilePage(true, {year_of_birth: '', requires_parental_consent: true}),
+ learnerProfileView = context.learnerProfileView;
+
+ LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView);
+ });
+
+ it("doesn't show the mode toggle if badges are disabled", function() {
+ var requests = AjaxHelpers.requests(this),
+ context = createProfilePage(true, {accomplishments_shared: false}),
+ tabbedView = context.learnerProfileView.tabbedView,
+ learnerProfileView = context.learnerProfileView;
+
+ LearnerProfileHelpers.expectTabbedViewToBeUndefined(requests, tabbedView);
+ LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
+ });
+
+ it("doesn't show the mode toggle if badges fail to fetch", function() {
+ var requests = AjaxHelpers.requests(this),
+ context = createProfilePage(true, {accomplishments_shared: false}),
+ tabbedView = context.learnerProfileView.tabbedView,
+ learnerProfileView = context.learnerProfileView;
+
+ LearnerProfileHelpers.expectTabbedViewToBeUndefined(requests, tabbedView);
+ LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
+ });
+
+ it('renders the mode toggle if there are badges', function() {
+ var requests = AjaxHelpers.requests(this),
+ context = createProfilePage(true, {accomplishments_shared: true}),
+ tabbedView = context.learnerProfileView.tabbedView;
+
+ AjaxHelpers.expectRequest(requests, 'POST', '/event');
+ AjaxHelpers.respondWithError(requests, 404);
+ AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges);
+
+ LearnerProfileHelpers.expectTabbedViewToBeShown(tabbedView);
+ });
+
+ it('renders the mode toggle if badges enabled but none exist', function() {
+ var requests = AjaxHelpers.requests(this),
+ context = createProfilePage(true, {accomplishments_shared: true}),
+ tabbedView = context.learnerProfileView.tabbedView;
+
+ AjaxHelpers.expectRequest(requests, 'POST', '/event');
+ AjaxHelpers.respondWithError(requests, 404);
+ AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.emptyBadges);
+
+ LearnerProfileHelpers.expectTabbedViewToBeShown(tabbedView);
+ });
+
+ it('displays the badges when the accomplishments toggle is selected', function() {
+ var requests = AjaxHelpers.requests(this),
+ context = createProfilePage(true, {accomplishments_shared: true}),
+ learnerProfileView = context.learnerProfileView,
+ tabbedView = learnerProfileView.tabbedView;
+
+ AjaxHelpers.expectRequest(requests, 'POST', '/event');
+ AjaxHelpers.respondWithError(requests, 404);
+ AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.secondPageBadges);
+
+ LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
+ tabbedView.$el.find('[data-url="accomplishments"]').click();
+ LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, false);
+ tabbedView.$el.find('[data-url="about_me"]').click();
+ LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
+ });
+
+ it('displays a placeholder on the last page of badges', function() {
+ var requests = AjaxHelpers.requests(this),
+ context = createProfilePage(true, {accomplishments_shared: true}),
+ learnerProfileView = context.learnerProfileView,
+ tabbedView = learnerProfileView.tabbedView;
+
+ AjaxHelpers.expectRequest(requests, 'POST', '/event');
+ AjaxHelpers.respondWithError(requests, 404);
+ AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.thirdPageBadges);
+
+ LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
+ tabbedView.$el.find('[data-url="accomplishments"]').click();
+ LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, true);
+ tabbedView.$el.find('[data-url="about_me"]').click();
+ LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
+ });
+
+ it('displays a placeholder when the accomplishments toggle is selected and no badges exist', function() {
+ var requests = AjaxHelpers.requests(this),
+ context = createProfilePage(true, {accomplishments_shared: true}),
+ learnerProfileView = context.learnerProfileView,
+ tabbedView = learnerProfileView.tabbedView;
+
+ AjaxHelpers.expectRequest(requests, 'POST', '/event');
+ AjaxHelpers.respondWithError(requests, 404);
+ AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.emptyBadges);
+
+ LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
+ tabbedView.$el.find('[data-url="accomplishments"]').click();
+ LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 0, true);
+ tabbedView.$el.find('[data-url="about_me"]').click();
+ LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
+ });
+
+ it('shows a paginated list of badges', function() {
+ var requests = AjaxHelpers.requests(this),
+ context = createProfilePage(true, {accomplishments_shared: true}),
+ learnerProfileView = context.learnerProfileView,
+ tabbedView = learnerProfileView.tabbedView;
+
+ AjaxHelpers.expectRequest(requests, 'POST', '/event');
+ AjaxHelpers.respondWithError(requests, 404);
+ AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges);
+
+ tabbedView.$el.find('[data-url="accomplishments"]').click();
+ LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, false);
+ LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.firstPageBadges);
+ });
+
+ it('allows forward and backward navigation of badges', function() {
+ var requests = AjaxHelpers.requests(this),
+ context = createProfilePage(true, {accomplishments_shared: true}),
+ learnerProfileView = context.learnerProfileView,
+ tabbedView = learnerProfileView.tabbedView,
+ badgeListContainer = context.badgeListContainer;
+
+ AjaxHelpers.expectRequest(requests, 'POST', '/event');
+ AjaxHelpers.respondWithError(requests, 404);
+ AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges);
+
+ tabbedView.$el.find('[data-url="accomplishments"]').click();
+
+ badgeListContainer.$el.find('.next-page-link').click();
+ AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.secondPageBadges);
+ LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.secondPageBadges);
+
+ badgeListContainer.$el.find('.next-page-link').click();
+ AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.thirdPageBadges);
+ LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, true);
+ LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.thirdPageBadges);
+
+ badgeListContainer.$el.find('.previous-page-link').click();
+ AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.secondPageBadges);
+ LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.secondPageBadges);
+ LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, false);
+
+ badgeListContainer.$el.find('.previous-page-link').click();
+ AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges);
+ LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.firstPageBadges);
+ });
+
+ it('renders the limited profile for under 13 users', function() {
+ var context = createProfilePage(
+ true,
+ {year_of_birth: new Date().getFullYear() - 10, requires_parental_consent: true}
+ );
+ var learnerProfileView = context.learnerProfileView;
+ LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView);
+ });
+ });
+ });
diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_container_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_container_spec.js
new file mode 100644
index 000000000000..20342c4a6709
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_container_spec.js
@@ -0,0 +1,99 @@
+define([
+ 'backbone',
+ 'jquery',
+ 'underscore',
+ 'URI',
+ 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
+ 'edx-ui-toolkit/js/pagination/paging-collection',
+ 'learner_profile/js/spec_helpers/helpers',
+ 'learner_profile/js/views/badge_list_container'
+],
+function(Backbone, $, _, URI, AjaxHelpers, PagingCollection, LearnerProfileHelpers, BadgeListContainer) {
+ 'use strict';
+
+ describe('edx.user.BadgeListContainer', function() {
+ var view;
+
+ var createView = function(requests, pageNum, badgeListObject) {
+ var BadgeCollection = PagingCollection.extend({
+ queryParams: {
+ currentPage: 'current_page'
+ }
+ });
+ var badgeCollection = new BadgeCollection();
+ var models = [];
+ var badgeListContainer;
+ var request;
+ var path;
+ badgeCollection.url = '/api/badges/v1/assertions/user/staff/';
+ _.each(_.range(badgeListObject.count), function(idx) {
+ models.push(LearnerProfileHelpers.makeBadge(idx));
+ });
+ badgeListObject.results = models; // eslint-disable-line no-param-reassign
+ badgeCollection.setPage(pageNum);
+ request = AjaxHelpers.currentRequest(requests);
+ path = new URI(request.url).path();
+ expect(path).toBe('/api/badges/v1/assertions/user/staff/');
+ AjaxHelpers.respondWithJson(requests, badgeListObject);
+ badgeListContainer = new BadgeListContainer({
+ collection: badgeCollection
+
+ });
+ badgeListContainer.render();
+ return badgeListContainer;
+ };
+
+ afterEach(function() {
+ view.$el.remove();
+ });
+
+ it('displays all badges', function() {
+ var requests = AjaxHelpers.requests(this),
+ badges;
+ view = createView(requests, 1, {
+ count: 30,
+ previous: '/arbitrary/url',
+ num_pages: 3,
+ next: null,
+ start: 20,
+ current_page: 1,
+ results: []
+ });
+ badges = view.$el.find('div.badge-display');
+ expect(badges.length).toBe(30);
+ });
+
+ it('displays placeholder on last page', function() {
+ var requests = AjaxHelpers.requests(this),
+ placeholder;
+ view = createView(requests, 3, {
+ count: 30,
+ previous: '/arbitrary/url',
+ num_pages: 3,
+ next: null,
+ start: 20,
+ current_page: 3,
+ results: []
+ });
+ placeholder = view.$el.find('span.accomplishment-placeholder');
+ expect(placeholder.length).toBe(1);
+ });
+
+ it('does not display placeholder on first page', function() {
+ var requests = AjaxHelpers.requests(this),
+ placeholder;
+ view = createView(requests, 1, {
+ count: 30,
+ previous: '/arbitrary/url',
+ num_pages: 3,
+ next: null,
+ start: 0,
+ current_page: 1,
+ results: []
+ });
+ placeholder = view.$el.find('span.accomplishment-placeholder');
+ expect(placeholder.length).toBe(0);
+ });
+ });
+}
+);
diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_view_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_view_spec.js
new file mode 100644
index 000000000000..e8cfd32d4c38
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_view_spec.js
@@ -0,0 +1,81 @@
+define([
+ 'backbone',
+ 'jquery',
+ 'underscore',
+ 'edx-ui-toolkit/js/pagination/paging-collection',
+ 'learner_profile/js/spec_helpers/helpers',
+ 'learner_profile/js/views/badge_list_view'
+],
+function(Backbone, $, _, PagingCollection, LearnerProfileHelpers, BadgeListView) {
+ 'use strict';
+
+ describe('edx.user.BadgeListView', function() {
+ var view;
+
+ var createView = function(badges, pages, page, hasNextPage) {
+ var badgeCollection = new PagingCollection();
+ var models = [];
+ var badgeList;
+ badgeCollection.url = '/api/badges/v1/assertions/user/staff/';
+ _.each(badges, function(element) {
+ models.push(new Backbone.Model(element));
+ });
+ badgeCollection.models = models;
+ badgeCollection.length = badges.length;
+ badgeCollection.currentPage = page;
+ badgeCollection.totalPages = pages;
+ badgeCollection.hasNextPage = function() {
+ return hasNextPage;
+ };
+ badgeList = new BadgeListView({
+ collection: badgeCollection
+
+ });
+ return badgeList;
+ };
+
+ afterEach(function() {
+ view.$el.remove();
+ });
+
+ it('there is a single row if there is only one badge', function() {
+ var rows;
+ view = createView([LearnerProfileHelpers.makeBadge(1)], 1, 1, false);
+ view.render();
+ rows = view.$el.find('div.row');
+ expect(rows.length).toBe(1);
+ });
+
+ it('accomplishments placeholder is visible on a last page', function() {
+ var placeholder;
+ view = createView([LearnerProfileHelpers.makeBadge(1)], 2, 2, false);
+ view.render();
+ placeholder = view.$el.find('span.accomplishment-placeholder');
+ expect(placeholder.length).toBe(1);
+ });
+
+ it('accomplishments placeholder to be not visible on a first page', function() {
+ var placeholder;
+ view = createView([LearnerProfileHelpers.makeBadge(1)], 1, 2, true);
+ view.render();
+ placeholder = view.$el.find('span.accomplishment-placeholder');
+ expect(placeholder.length).toBe(0);
+ });
+
+ it('badges are in two columns (checked by counting rows for a known number of badges)', function() {
+ var badges = [];
+ var placeholder;
+ var rows;
+ _.each(_.range(4), function(item) {
+ badges.push(LearnerProfileHelpers.makeBadge(item));
+ });
+ view = createView(badges, 1, 2, true);
+ view.render();
+ placeholder = view.$el.find('span.accomplishment-placeholder');
+ expect(placeholder.length).toBe(0);
+ rows = view.$el.find('div.row');
+ expect(rows.length).toBe(2);
+ });
+ });
+}
+);
diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_view_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_view_spec.js
new file mode 100644
index 000000000000..8ac88ae0b17e
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_view_spec.js
@@ -0,0 +1,114 @@
+define([
+ 'backbone', 'jquery', 'underscore',
+ 'learner_profile/js/spec_helpers/helpers',
+ 'learner_profile/js/views/badge_view'
+],
+function(Backbone, $, _, LearnerProfileHelpers, BadgeView) {
+ 'use strict';
+
+ describe('edx.user.BadgeView', function() {
+ var view,
+ badge,
+ testBadgeNameIsDisplayed,
+ testBadgeIconIsDisplayed;
+
+ var createView = function(ownProfile) {
+ var options,
+ testView;
+ badge = LearnerProfileHelpers.makeBadge(1);
+ options = {
+ model: new Backbone.Model(badge),
+ ownProfile: ownProfile,
+ badgeMeta: {}
+ };
+ testView = new BadgeView(options);
+ testView.render();
+ $('body').append(testView.$el);
+ testView.$el.show();
+ expect(testView.$el.is(':visible')).toBe(true);
+ return testView;
+ };
+
+ afterEach(function() {
+ view.$el.remove();
+ $('.badges-modal').remove();
+ });
+
+ it('profile of other has no share button', function() {
+ view = createView(false);
+ expect(view.context.ownProfile).toBeFalsy();
+ expect(view.$el.find('button.share-button').length).toBe(0);
+ });
+
+ it('own profile has share button', function() {
+ view = createView(true);
+ expect(view.context.ownProfile).toBeTruthy();
+ expect(view.$el.find('button.share-button').length).toBe(1);
+ });
+
+ it('click on share button calls createModal function', function() {
+ var shareButton;
+ view = createView(true);
+ spyOn(view, 'createModal');
+ view.delegateEvents();
+ expect(view.context.ownProfile).toBeTruthy();
+ shareButton = view.$el.find('button.share-button');
+ expect(shareButton.length).toBe(1);
+ expect(view.createModal).not.toHaveBeenCalled();
+ shareButton.click();
+ expect(view.createModal).toHaveBeenCalled();
+ });
+
+ it('click on share button calls shows the dialog', function(done) {
+ var shareButton,
+ $modalElement;
+ view = createView(true);
+ expect(view.context.ownProfile).toBeTruthy();
+ shareButton = view.$el.find('button.share-button');
+ expect(shareButton.length).toBe(1);
+ $modalElement = $('.badges-modal');
+ expect($modalElement.length).toBe(0);
+ expect($modalElement.is(':visible')).toBeFalsy();
+ shareButton.click();
+ // Note: this element should have appeared in the dom during: shareButton.click();
+ $modalElement = $('.badges-modal');
+ jasmine.waitUntil(function() {
+ return $modalElement.is(':visible');
+ }).always(done);
+ });
+
+ testBadgeNameIsDisplayed = function(ownProfile) {
+ var badgeDiv;
+ view = createView(ownProfile);
+ badgeDiv = view.$el.find('.badge-name');
+ expect(badgeDiv.length).toBeTruthy();
+ expect(badgeDiv.is(':visible')).toBe(true);
+ expect(_.count(badgeDiv.html(), badge.badge_class.display_name)).toBeTruthy();
+ };
+
+ it('test badge name is displayed for own profile', function() {
+ testBadgeNameIsDisplayed(true);
+ });
+
+ it('test badge name is displayed for other profile', function() {
+ testBadgeNameIsDisplayed(false);
+ });
+
+ testBadgeIconIsDisplayed = function(ownProfile) {
+ var badgeImg;
+ view = createView(ownProfile);
+ badgeImg = view.$el.find('img.badge');
+ expect(badgeImg.length).toBe(1);
+ expect(badgeImg.attr('src')).toEqual(badge.image_url);
+ };
+
+ it('test badge icon is displayed for own profile', function() {
+ testBadgeIconIsDisplayed(true);
+ });
+
+ it('test badge icon is displayed for other profile', function() {
+ testBadgeIconIsDisplayed(false);
+ });
+ });
+}
+);
diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_fields_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_fields_spec.js
new file mode 100644
index 000000000000..49b3dbc630df
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_fields_spec.js
@@ -0,0 +1,381 @@
+define(
+ [
+ 'backbone',
+ 'jquery',
+ 'underscore',
+ 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
+ 'common/js/spec_helpers/template_helpers',
+ 'js/spec/student_account/helpers',
+ 'js/student_account/models/user_account_model',
+ 'learner_profile/js/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 messageView;
+
+ var imageData = {
+ image_url_large: '/media/profile-images/default.jpg',
+ has_image: !!options.hasImage
+ };
+
+ var accountSettingsModel = new UserAccountModel();
+ accountSettingsModel.set({profile_image: imageData});
+ accountSettingsModel.set({year_of_birth: yearOfBirth});
+ accountSettingsModel.set({requires_parental_consent: !!_.isEmpty(yearOfBirth)});
+
+ accountSettingsModel.url = Helpers.USER_ACCOUNTS_API_URL;
+
+ 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
+ });
+ };
+
+ var createSocialLinksView = function(ownProfile, socialPlatformLinks) {
+ var accountSettingsModel = new UserAccountModel();
+ accountSettingsModel.set({social_platforms: socialPlatformLinks});
+
+ return new LearnerProfileFields.SocialLinkIconsView({
+ model: accountSettingsModel,
+ socialPlatforms: ['twitter', 'facebook', 'linkedin'],
+ ownProfile: ownProfile
+ });
+ };
+
+ 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
+ });
+ };
+
+ beforeEach(function() {
+ loadFixtures('learner_profile/fixtures/learner_profile.html');
+ TemplateHelpers.installTemplate('templates/fields/field_image');
+ TemplateHelpers.installTemplate('templates/fields/message_banner');
+ TemplateHelpers.installTemplate('learner_profile/templates/social_icons');
+ });
+
+ afterEach(function() {
+ // image_field.js's window.onBeforeUnload breaks Karma in Chrome, clean it up after each test
+ $(window).off('beforeunload');
+ });
+
+ 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 span').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 span').attr('class')).toContain(iconName);
+ expect(view.$('.remove-button-title').text().trim()).toBe(message);
+ };
+
+ it('can upload profile image', function() {
+ var requests = AjaxHelpers.requests(this);
+ var imageName = 'profile_image.jpg';
+ var imageView = createImageView({ownProfile: true, hasImage: false});
+ var data;
+ imageView.render();
+
+ 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
+ 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 requests = AjaxHelpers.requests(this);
+ var imageView = createImageView({ownProfile: true, hasImage: false});
+ var data;
+ imageView.render();
+
+ // 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
+ 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('The file 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('The file 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.onbeforeunload = null;
+ $(window).trigger('beforeunload');
+ expect(imageView.onBeforeUnload).toHaveBeenCalled();
+ });
+
+ it('shows error message for HTTP 500', function() {
+ var requests = AjaxHelpers.requests(this);
+ var imageView = createImageView({ownProfile: true, hasImage: false});
+ imageView.render();
+
+ 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);
+ });
+ });
+
+ describe('SocialLinkIconsView', function() {
+ var socialPlatformLinks,
+ socialLinkData,
+ socialLinksView,
+ socialPlatform,
+ $icon;
+
+ it('icons are visible and links to social profile if added in account settings', function() {
+ socialPlatformLinks = {
+ twitter: {
+ platform: 'twitter',
+ social_link: 'https://www.twitter.com/edX'
+ },
+ facebook: {
+ platform: 'facebook',
+ social_link: 'https://www.facebook.com/edX'
+ },
+ linkedin: {
+ platform: 'linkedin',
+ social_link: ''
+ }
+ };
+
+ socialLinksView = createSocialLinksView(true, socialPlatformLinks);
+
+ // Icons should be present and contain links if defined
+ for (var i = 0; i < Object.keys(socialPlatformLinks); i++) { // eslint-disable-line vars-on-top
+ socialPlatform = Object.keys(socialPlatformLinks)[i];
+ socialLinkData = socialPlatformLinks[socialPlatform];
+ if (socialLinkData.social_link) {
+ // Icons with a social_link value should be displayed with a surrounding link
+ $icon = socialLinksView.$('span.fa-' + socialPlatform + '-square');
+ expect($icon).toExist();
+ expect($icon.parent().is('a'));
+ } else {
+ // Icons without a social_link value should be displayed without a surrounding link
+ $icon = socialLinksView.$('span.fa-' + socialPlatform + '-square');
+ expect($icon).toExist();
+ expect(!$icon.parent().is('a'));
+ }
+ }
+ });
+
+ it('icons are not visible on a profile with no links', function() {
+ socialPlatformLinks = {
+ twitter: {
+ platform: 'twitter',
+ social_link: ''
+ },
+ facebook: {
+ platform: 'facebook',
+ social_link: ''
+ },
+ linkedin: {
+ platform: 'linkedin',
+ social_link: ''
+ }
+ };
+
+ socialLinksView = createSocialLinksView(false, socialPlatformLinks);
+
+ // Icons should not be present if not defined on another user's profile
+ for (var i = 0; i < Object.keys(socialPlatformLinks); i++) { // eslint-disable-line vars-on-top
+ socialPlatform = Object.keys(socialPlatformLinks)[i];
+ socialLinkData = socialPlatformLinks[socialPlatform];
+ $icon = socialLinksView.$('span.fa-' + socialPlatform + '-square');
+ expect($icon).toBe(null);
+ }
+ });
+ });
+ });
+ });
diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_view_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_view_spec.js
new file mode 100644
index 000000000000..1797b0de05e7
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_view_spec.js
@@ -0,0 +1,240 @@
+/* eslint-disable vars-on-top */
+define(
+ [
+ 'gettext',
+ 'backbone',
+ 'jquery',
+ 'underscore',
+ 'edx-ui-toolkit/js/pagination/paging-collection',
+ 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
+ 'common/js/spec_helpers/template_helpers',
+ 'js/spec/student_account/helpers',
+ 'learner_profile/js/spec_helpers/helpers',
+ 'js/views/fields',
+ 'js/student_account/models/user_account_model',
+ 'js/student_account/models/user_preferences_model',
+ 'learner_profile/js/views/learner_profile_fields',
+ 'learner_profile/js/views/learner_profile_view',
+ 'learner_profile/js/views/badge_list_container',
+ 'js/student_account/views/account_settings_fields',
+ 'js/views/message_banner'
+ ],
+ function(gettext, Backbone, $, _, PagingCollection, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers,
+ FieldViews, UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView,
+ BadgeListContainer, 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 nameFieldView = new FieldViews.ReadonlyFieldView({
+ model: accountSettingsModel,
+ valueAttribute: 'name',
+ helpMessage: ''
+ });
+
+ var sectionOneFieldViews = [
+ new LearnerProfileFields.SocialLinkIconsView({
+ model: accountSettingsModel,
+ socialPlatforms: Helpers.SOCIAL_PLATFORMS,
+ ownProfile: true
+ }),
+
+ new FieldViews.DropdownFieldView({
+ title: gettext('Location'),
+ model: accountSettingsModel,
+ required: false,
+ editable: editable,
+ showMessages: false,
+ placeholderValue: '',
+ valueAttribute: 'country',
+ options: Helpers.FIELD_OPTIONS,
+ helpMessage: ''
+ }),
+
+ new AccountSettingsFieldViews.LanguageProficienciesFieldView({
+ title: gettext('Language'),
+ model: accountSettingsModel,
+ required: false,
+ editable: editable,
+ showMessages: false,
+ placeholderValue: 'Add language',
+ valueAttribute: 'language_proficiencies',
+ options: Helpers.FIELD_OPTIONS,
+ helpMessage: ''
+ }),
+
+ new FieldViews.DateFieldView({
+ model: accountSettingsModel,
+ valueAttribute: 'date_joined',
+ 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: '',
+ messagePosition: 'header'
+ })
+ ];
+
+ var badgeCollection = new PagingCollection();
+ badgeCollection.url = Helpers.BADGES_API_URL;
+
+ var badgeListContainer = new BadgeListContainer({
+ attributes: {class: 'badge-set-display'},
+ collection: badgeCollection,
+ find_courses_url: Helpers.FIND_COURSES_URL
+ });
+
+ return new LearnerProfileView(
+ {
+ el: $('.wrapper-profile'),
+ ownProfile: ownProfile,
+ hasPreferencesAccess: true,
+ accountSettingsModel: accountSettingsModel,
+ preferencesModel: accountPreferencesModel,
+ accountPrivacyFieldView: accountPrivacyFieldView,
+ usernameFieldView: usernameFieldView,
+ nameFieldView: nameFieldView,
+ profileImageFieldView: profileImageFieldView,
+ sectionOneFieldViews: sectionOneFieldViews,
+ sectionTwoFieldViews: sectionTwoFieldViews,
+ badgeListContainer: badgeListContainer
+ });
+ };
+
+ beforeEach(function() {
+ loadFixtures('learner_profile/fixtures/learner_profile.html');
+ });
+
+ afterEach(function() {
+ Backbone.history.stop();
+ });
+
+ 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);
+ });
+
+ it("renders an error if the badges can't be fetched", function() {
+ var learnerProfileView = createLearnerProfileView(false, 'all_users', true);
+ learnerProfileView.options.accountSettingsModel.set({accomplishments_shared: true});
+ var requests = AjaxHelpers.requests(this);
+
+ learnerProfileView.render();
+
+ LearnerProfileHelpers.breakBadgeLoading(learnerProfileView, requests);
+ LearnerProfileHelpers.expectBadgeLoadingErrorIsRendered(learnerProfileView);
+ });
+ });
+ });
diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/section_two_tab_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/section_two_tab_spec.js
new file mode 100644
index 000000000000..d0e22d670beb
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/js/spec/views/section_two_tab_spec.js
@@ -0,0 +1,113 @@
+/* eslint-disable vars-on-top */
+define(
+ [
+ 'backbone', 'jquery', 'underscore',
+ 'js/spec/student_account/helpers',
+ 'learner_profile/js/views/section_two_tab',
+ 'js/views/fields',
+ 'js/student_account/models/user_account_model'
+ ],
+ function(Backbone, $, _, Helpers, SectionTwoTabView, FieldViews, UserAccountModel) {
+ 'use strict';
+
+ describe('edx.user.SectionTwoTab', function() {
+ var createSectionTwoView = function(ownProfile, profileIsPublic) {
+ var accountSettingsModel = new UserAccountModel();
+ accountSettingsModel.set(Helpers.createAccountSettingsData());
+ accountSettingsModel.set({profile_is_public: profileIsPublic});
+ accountSettingsModel.set({profile_image: Helpers.PROFILE_IMAGE});
+
+ var editable = ownProfile ? 'toggle' : 'never';
+
+ 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: '',
+ messagePosition: 'header'
+ })
+ ];
+
+ return new SectionTwoTabView({
+ viewList: sectionTwoFieldViews,
+ showFullProfile: function() {
+ return profileIsPublic;
+ },
+ ownProfile: ownProfile
+ });
+ };
+
+ it('full profile displayed for public profile', function() {
+ var view = createSectionTwoView(false, true);
+ view.render();
+ var bio = view.$el.find('.u-field-bio');
+ expect(bio.length).toBe(1);
+ });
+
+ it('profile field parts are actually rendered for public profile', function() {
+ var view = createSectionTwoView(false, true);
+ _.each(view.options.viewList, function(fieldView) {
+ spyOn(fieldView, 'render').and.callThrough();
+ });
+ view.render();
+ _.each(view.options.viewList, function(fieldView) {
+ expect(fieldView.render).toHaveBeenCalled();
+ });
+ });
+
+ var testPrivateProfile = function(ownProfile, messageString) {
+ var view = createSectionTwoView(ownProfile, false);
+ view.render();
+ var bio = view.$el.find('.u-field-bio');
+ expect(bio.length).toBe(0);
+ var msg = view.$el.find('span.profile-private-message');
+ expect(msg.length).toBe(1);
+ expect(_.count(msg.html(), messageString)).toBeTruthy();
+ };
+
+ it('no profile when profile is private for other people', function() {
+ testPrivateProfile(false, 'This learner is currently sharing a limited profile');
+ });
+
+ it('no profile when profile is private for the user herself', function() {
+ testPrivateProfile(true, 'You are currently sharing a limited profile');
+ });
+
+ var testProfilePrivatePartsDoNotRender = function(ownProfile) {
+ var view = createSectionTwoView(ownProfile, false);
+ _.each(view.options.viewList, function(fieldView) {
+ spyOn(fieldView, 'render');
+ });
+ view.render();
+ _.each(view.options.viewList, function(fieldView) {
+ expect(fieldView.render).not.toHaveBeenCalled();
+ });
+ };
+
+ it('profile field parts are not rendered for private profile for owner', function() {
+ testProfilePrivatePartsDoNotRender(true);
+ });
+
+ it('profile field parts are not rendered for private profile for other people', function() {
+ testProfilePrivatePartsDoNotRender(false);
+ });
+
+ it('does not allow fields to be edited when visiting a profile for other people', function() {
+ var view = createSectionTwoView(false, true);
+ var bio = view.options.viewList[0];
+ expect(bio.editable).toBe('never');
+ });
+
+ it("allows fields to be edited when visiting one's own profile", function() {
+ var view = createSectionTwoView(true, true);
+ var bio = view.options.viewList[0];
+ expect(bio.editable).toBe('toggle');
+ });
+ });
+ }
+);
diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/share_modal_view_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/share_modal_view_spec.js
new file mode 100644
index 000000000000..e3d15659ffa6
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/js/spec/views/share_modal_view_spec.js
@@ -0,0 +1,63 @@
+define(
+ [
+ 'backbone', 'jquery', 'underscore', 'moment',
+ 'js/spec/student_account/helpers',
+ 'learner_profile/js/spec_helpers/helpers',
+ 'learner_profile/js/views/share_modal_view',
+ 'jquery.simulate'
+ ],
+ function(Backbone, $, _, Moment, Helpers, LearnerProfileHelpers, ShareModalView) {
+ 'use strict';
+
+ describe('edx.user.ShareModalView', function() {
+ var keys = $.simulate.keyCode;
+
+ var view;
+
+ var createModalView = function() {
+ var badge = LearnerProfileHelpers.makeBadge(1);
+ var context = _.extend(badge, {
+ created: new Moment(badge.created),
+ ownProfile: true,
+ badgeMeta: {}
+ });
+ return new ShareModalView({
+ model: new Backbone.Model(context),
+ shareButton: $('')
+ });
+ };
+
+ beforeEach(function() {
+ view = createModalView();
+ // Attach view to document, otherwise click won't work
+ view.render();
+ $('body').append(view.$el);
+ view.$el.show();
+ expect(view.$el.is(':visible')).toBe(true);
+ });
+
+ afterEach(function() {
+ view.$el.remove();
+ });
+
+ it('modal view closes on escape', function() {
+ spyOn(view, 'close');
+ view.delegateEvents();
+ expect(view.close).not.toHaveBeenCalled();
+ $(view.$el).simulate('keydown', {keyCode: keys.ESCAPE});
+ expect(view.close).toHaveBeenCalled();
+ });
+
+ it('modal view closes click on close', function() {
+ var $closeButton;
+ spyOn(view, 'close');
+ view.delegateEvents();
+ $closeButton = view.$el.find('button.close');
+ expect($closeButton.length).toBe(1);
+ expect(view.close).not.toHaveBeenCalled();
+ $closeButton.trigger('click');
+ expect(view.close).toHaveBeenCalled();
+ });
+ });
+ }
+);
diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec_helpers/helpers.js b/openedx/features/learner_profile/static/learner_profile/js/spec_helpers/helpers.js
new file mode 100644
index 000000000000..5d4a278907ca
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/js/spec_helpers/helpers.js
@@ -0,0 +1,259 @@
+define(['underscore', 'URI', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers'], function(_, URI, AjaxHelpers) {
+ 'use strict';
+
+ var expectProfileElementContainsField = function(element, view) {
+ var titleElement, fieldTitle;
+ var $element = $(element);
+
+ // Avoid testing for elements without titles
+ titleElement = $element.find('.u-field-title');
+ if (titleElement.length === 0) {
+ return;
+ }
+
+ fieldTitle = titleElement.text().trim();
+ if (!_.isUndefined(view.options.title) && !_.isUndefined(fieldTitle)) {
+ 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.fieldType === 'date') {
+ expect(view.fieldValue()).toBe(view.timezoneFormattedDate());
+ } 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 = $('.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, .social-links');
+
+ expect(sectionOneFieldElements.length).toBe(7);
+ expectProfileElementContainsField(sectionOneFieldElements[0], learnerProfileView.options.profileImageFieldView);
+ expectProfileElementContainsField(sectionOneFieldElements[1], learnerProfileView.options.usernameFieldView);
+ expectProfileElementContainsField(sectionOneFieldElements[2], learnerProfileView.options.nameFieldView);
+
+ _.each(_.rest(sectionOneFieldElements, 3), function(sectionFieldElement, fieldIndex) {
+ expectProfileElementContainsField(
+ sectionFieldElement,
+ learnerProfileView.options.sectionOneFieldViews[fieldIndex]
+ );
+ });
+ };
+
+ var expectSectionTwoTobeRendered = function(learnerProfileView) {
+ var $sectionTwoElement = $('.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) {
+ var sectionOneFieldElements = $('.wrapper-profile-section-one').find('.u-field');
+
+ expectProfilePrivacyFieldTobeRendered(learnerProfileView, othersProfile);
+
+ 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 learner is currently sharing a limited profile.');
+ } else {
+ expect($('.profile-private-message').text()).toBe('You are currently sharing a limited profile.');
+ }
+ };
+
+ var expectProfileSectionsNotToBeRendered = function() {
+ expect($('.wrapper-profile-field-account-privacy').length).toBe(0);
+ expect($('.wrapper-profile-section-one').length).toBe(0);
+ expect($('.wrapper-profile-section-two').length).toBe(0);
+ };
+
+ var expectTabbedViewToBeUndefined = function(requests, tabbedViewView) {
+ // Unrelated initial request, no badge request
+ expect(requests.length).toBe(1);
+ expect(tabbedViewView).toBe(undefined);
+ };
+
+ var expectTabbedViewToBeShown = function(tabbedViewView) {
+ expect(tabbedViewView.$el.find('.page-content-nav').is(':visible')).toBe(true);
+ };
+
+ var expectBadgesDisplayed = function(learnerProfileView, length, lastPage) {
+ var $badgeListingView = $('#tabpanel-accomplishments'),
+ updatedLength = length,
+ placeholder;
+ expect($('#tabpanel-about_me').hasClass('is-hidden')).toBe(true);
+ expect($badgeListingView.hasClass('is-hidden')).toBe(false);
+ if (lastPage) {
+ updatedLength += 1;
+ placeholder = $badgeListingView.find('.find-course');
+ expect(placeholder.length).toBe(1);
+ expect(placeholder.attr('href')).toBe('/courses/');
+ }
+ expect($badgeListingView.find('.badge-display').length).toBe(updatedLength);
+ };
+
+ var expectBadgesHidden = function() {
+ var $accomplishmentsTab = $('#tabpanel-accomplishments');
+ if ($accomplishmentsTab.length) {
+ // Nonexistence counts as hidden.
+ expect($('#tabpanel-accomplishments').hasClass('is-hidden')).toBe(true);
+ }
+ expect($('#tabpanel-about_me').hasClass('is-hidden')).toBe(false);
+ };
+
+ var expectPage = function(learnerProfileView, pageData) {
+ var $badgeListContainer = $('#tabpanel-accomplishments');
+ var index = $badgeListContainer.find('span.search-count').text().trim();
+ expect(index).toBe('Showing ' + (pageData.start + 1) + '-' + (pageData.start + pageData.results.length)
+ + ' out of ' + pageData.count + ' total');
+ expect($badgeListContainer.find('.current-page').text()).toBe('' + pageData.current_page);
+ _.each(pageData.results, function(badge) {
+ expect($('.badge-display:contains(' + badge.badge_class.display_name + ')').length).toBe(1);
+ });
+ };
+
+ var expectBadgeLoadingErrorIsRendered = function() {
+ var errorMessage = $('.badge-set-display').text();
+ expect(errorMessage).toBe(
+ 'Your request could not be completed. Reload the page and try again. If the issue persists, click the '
+ + 'Help tab to report the problem.'
+ );
+ };
+
+ var breakBadgeLoading = function(learnerProfileView, requests) {
+ var request = AjaxHelpers.currentRequest(requests);
+ var path = new URI(request.url).path();
+ expect(path).toBe('/api/badges/v1/assertions/user/student/');
+ AjaxHelpers.respondWithError(requests, 500);
+ };
+
+ var firstPageBadges = {
+ count: 30,
+ previous: null,
+ next: '/arbitrary/url',
+ num_pages: 3,
+ start: 0,
+ current_page: 1,
+ results: []
+ };
+
+ var secondPageBadges = {
+ count: 30,
+ previous: '/arbitrary/url',
+ next: '/arbitrary/url',
+ num_pages: 3,
+ start: 10,
+ current_page: 2,
+ results: []
+ };
+
+ var thirdPageBadges = {
+ count: 30,
+ previous: '/arbitrary/url',
+ num_pages: 3,
+ next: null,
+ start: 20,
+ current_page: 3,
+ results: []
+ };
+
+ var emptyBadges = {
+ count: 0,
+ previous: null,
+ num_pages: 1,
+ results: []
+ };
+
+ function makeBadge(num) {
+ return {
+ badge_class: {
+ slug: 'test_slug_' + num,
+ issuing_component: 'test_component',
+ display_name: 'Test Badge ' + num,
+ course_id: null,
+ description: "Yay! It's a test badge.",
+ criteria: 'https://example.com/syllabus',
+ image_url: 'http://localhost:8000/media/badge_classes/test_lMB9bRw.png'
+ },
+ image_url: 'http://example.com/image.png',
+ assertion_url: 'http://example.com/example.json',
+ created_at: '2015-12-03T16:25:57.676113Z'
+ };
+ }
+
+ _.each(_.range(0, 10), function(i) {
+ firstPageBadges.results.push(makeBadge(i));
+ });
+
+ _.each(_.range(10, 20), function(i) {
+ secondPageBadges.results.push(makeBadge(i));
+ });
+
+ _.each(_.range(20, 30), function(i) {
+ thirdPageBadges.results.push(makeBadge(i));
+ });
+
+ return {
+ expectLimitedProfileSectionsAndFieldsToBeRendered: expectLimitedProfileSectionsAndFieldsToBeRendered,
+ expectProfileSectionsAndFieldsToBeRendered: expectProfileSectionsAndFieldsToBeRendered,
+ expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered,
+ expectTabbedViewToBeUndefined: expectTabbedViewToBeUndefined,
+ expectTabbedViewToBeShown: expectTabbedViewToBeShown,
+ expectBadgesDisplayed: expectBadgesDisplayed,
+ expectBadgesHidden: expectBadgesHidden,
+ expectBadgeLoadingErrorIsRendered: expectBadgeLoadingErrorIsRendered,
+ breakBadgeLoading: breakBadgeLoading,
+ firstPageBadges: firstPageBadges,
+ secondPageBadges: secondPageBadges,
+ thirdPageBadges: thirdPageBadges,
+ emptyBadges: emptyBadges,
+ expectPage: expectPage,
+ makeBadge: makeBadge
+ };
+});
diff --git a/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js b/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js
new file mode 100644
index 000000000000..ca68ca8fa9e8
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js
@@ -0,0 +1,35 @@
+/* eslint-disable no-underscore-dangle */
+(function(define) {
+ 'use strict';
+
+ define(
+ [
+ 'gettext', 'jquery', 'underscore', 'common/js/components/views/paginated_view',
+ 'learner_profile/js/views/badge_view', 'learner_profile/js/views/badge_list_view',
+ 'text!learner_profile/templates/badge_list.underscore'
+ ],
+ function(gettext, $, _, PaginatedView, BadgeView, BadgeListView, BadgeListTemplate) {
+ var BadgeListContainer = PaginatedView.extend({
+ type: 'badge',
+
+ itemViewClass: BadgeView,
+
+ listViewClass: BadgeListView,
+
+ viewTemplate: BadgeListTemplate,
+
+ isZeroIndexed: true,
+
+ paginationLabel: gettext('Accomplishments Pagination'),
+
+ initialize: function(options) {
+ BadgeListContainer.__super__.initialize.call(this, options);
+ this.listView.find_courses_url = options.find_courses_url;
+ this.listView.badgeMeta = options.badgeMeta;
+ this.listView.ownProfile = options.ownProfile;
+ }
+ });
+
+ return BadgeListContainer;
+ });
+}).call(this, define || RequireJS.define);
diff --git a/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_view.js b/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_view.js
new file mode 100644
index 000000000000..c30ab9c94ad9
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_view.js
@@ -0,0 +1,65 @@
+(function(define) {
+ 'use strict';
+
+ define([
+ 'gettext',
+ 'jquery',
+ 'underscore',
+ 'edx-ui-toolkit/js/utils/html-utils',
+ 'common/js/components/views/list',
+ 'learner_profile/js/views/badge_view',
+ 'text!learner_profile/templates/badge_placeholder.underscore'
+ ],
+ function(gettext, $, _, HtmlUtils, ListView, BadgeView, badgePlaceholder) {
+ var BadgeListView = ListView.extend({
+ tagName: 'div',
+
+ template: HtmlUtils.template(badgePlaceholder),
+
+ renderCollection: function() {
+ var self = this,
+ $row;
+
+ this.$el.empty();
+
+ // Split into two columns.
+ this.collection.each(function(badge, index) {
+ var $item;
+ if (index % 2 === 0) {
+ $row = $('
');
+ this.$el.append($row);
+ }
+ $item = new BadgeView({
+ model: badge,
+ badgeMeta: this.badgeMeta,
+ ownProfile: this.ownProfile
+ }).render().el;
+
+ if ($row) {
+ $row.append($item);
+ }
+
+ this.itemViews.push($item);
+ }, this);
+ // Placeholder must always be at the end, and may need a new row.
+ if (!this.collection.hasNextPage()) {
+ // find_courses_url set by BadgeListContainer during initialization.
+ if (this.collection.length % 2 === 0) {
+ $row = $('
diff --git a/openedx/features/learner_profile/static/learner_profile/templates/section_two.underscore b/openedx/features/learner_profile/static/learner_profile/templates/section_two.underscore
new file mode 100644
index 000000000000..0c7d11cd8b18
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/templates/section_two.underscore
@@ -0,0 +1,10 @@
+
+
+ <% if (!showFullProfile) { %>
+ <% if(ownProfile) { %>
+ <%- gettext("You are currently sharing a limited profile.") %>
+ <% } else { %>
+ <%- gettext("This learner is currently sharing a limited profile.") %>
+ <% } %>
+ <% } %>
+
\ No newline at end of file
diff --git a/openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore b/openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
new file mode 100644
index 000000000000..b71e89823bad
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
@@ -0,0 +1,41 @@
+
+
+
+
<%- gettext("Share on Mozilla Backpack") %>
+
<%- gettext("To share your certificate on Mozilla Backpack, you must first have a Backpack account. Complete the following steps to add your certificate to Backpack.") %>
+
+
+
+
+
+ <%= edx.HtmlUtils.interpolateHtml(
+ gettext("Create a {link_start}Mozilla Backpack{link_end} account, or log in to your existing account"),
+ {
+ link_start: edx.HtmlUtils.HTML(''),
+ link_end: edx.HtmlUtils.HTML('')
+ }
+ )
+ %>
+
+
+
+ <%= edx.HtmlUtils.interpolateHtml(
+ gettext("{download_link_start}Download this image (right-click or option-click, save as){link_end} and then {upload_link_start}upload{link_end} it to your backpack."),
+ {
+ download_link_start: edx.HtmlUtils.joinHtml(
+ edx.HtmlUtils.HTML(''),
+ ),
+ link_end: edx.HtmlUtils.HTML(''),
+ upload_link_start: edx.HtmlUtils.HTML('')
+ }
+ )
+ %>
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/openedx/features/learner_profile/static/learner_profile/templates/social_icons.underscore b/openedx/features/learner_profile/static/learner_profile/templates/social_icons.underscore
new file mode 100644
index 000000000000..52b864cfb669
--- /dev/null
+++ b/openedx/features/learner_profile/static/learner_profile/templates/social_icons.underscore
@@ -0,0 +1,9 @@
+
+ <% for (var platform in socialLinks) { %>
+ <% if (socialLinks[platform]) { %>
+ >
+ aria-hidden="true">
+
+ <% } %>
+ <% } %>
+
+
+ ## Translators: this section lists all the third-party authentication providers (for example, Google and LinkedIn) the user can link with or unlink from their edX account.
+ ${_("Connected Accounts")}
+
+
+
+ % for state in provider_user_states:
+