diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index c6d31c588a..34bb107243 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -44,7 +44,7 @@ from openedx.core.djangoapps.util.maintenance_banner import add_maintenance_bann from openedx.core.djangolib.markup import HTML, Text from openedx.features.enterprise_support.api import ( get_dashboard_consent_notification, - get_enterprise_learner_portal_enabled_message + get_enterprise_learner_portal_context, ) from common.djangoapps.student.api import COURSE_DASHBOARD_PLUGIN_VIEW_NAME from common.djangoapps.student.helpers import cert_info, check_verify_status_by_course, get_resume_urls_for_enrollments @@ -576,9 +576,6 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem enterprise_message = get_dashboard_consent_notification(request, user, course_enrollments) - # Display a message guiding the user to their Enterprise's Learner Portal if enabled - enterprise_learner_portal_enabled_message = get_enterprise_learner_portal_enabled_message(request) - recovery_email_message = recovery_email_activation_message = None if is_secondary_email_feature_enabled(): try: @@ -605,11 +602,9 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem ) ) - -# Disable lookup of Enterprise consent_required_course due to ENT-727 + # Disable lookup of Enterprise consent_required_course due to ENT-727 # Will re-enable after fixing WL-1315 consent_required_courses = set() - enterprise_customer_name = None # Account activation message account_activation_messages = [ @@ -752,7 +747,6 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem 'programs_data': programs_data, 'enterprise_message': enterprise_message, 'consent_required_courses': consent_required_courses, - 'enterprise_customer_name': enterprise_customer_name, 'enrollment_message': enrollment_message, 'redirect_message': Text(redirect_message), 'account_activation_messages': account_activation_messages, @@ -796,13 +790,16 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem 'empty_dashboard_message': empty_dashboard_message, 'recovery_email_message': recovery_email_message, 'recovery_email_activation_message': recovery_email_activation_message, - 'enterprise_learner_portal_enabled_message': enterprise_learner_portal_enabled_message, 'show_load_all_courses_link': show_load_all_courses_link(user, course_limit, course_enrollments), # TODO START: clean up as part of REVEM-199 (START) 'course_info': get_dashboard_course_info(user, course_enrollments), # TODO START: clean up as part of REVEM-199 (END) } + # Include enterprise learner portal metadata and messaging + enterprise_learner_portal_context = get_enterprise_learner_portal_context(request) + context.update(enterprise_learner_portal_context) + context_from_plugins = get_plugins_view_context( ProjectType.LMS, COURSE_DASHBOARD_PLUGIN_VIEW_NAME, diff --git a/lms/static/js/learner_dashboard/EnterpriseLearnerPortalModal.jsx b/lms/static/js/learner_dashboard/EnterpriseLearnerPortalModal.jsx new file mode 100644 index 0000000000..884718855f --- /dev/null +++ b/lms/static/js/learner_dashboard/EnterpriseLearnerPortalModal.jsx @@ -0,0 +1,126 @@ +/* global gettext */ +import React from 'react'; +import FocusLock, { AutoFocusInside } from 'react-focus-lock'; +import StringUtils from 'edx-ui-toolkit/js/utils/string-utils'; + +class EnterpriseLearnerPortalModal extends React.Component { + constructor(props) { + super(props); + this.state = { + isModalOpen: false, + }; + + this.openModal = this.openModal.bind(this); + this.closeModal = this.closeModal.bind(this); + this.handleClick = this.handleClick.bind(this); + } + + componentDidMount() { + const storageKey = `enterprise_learner_portal_modal__${this.props.enterpriseCustomerUUID}`; + const hasViewedModal = window.sessionStorage.getItem(storageKey); + if (!hasViewedModal) { + this.openModal(); + document.addEventListener('mousedown', this.handleClick, false); + window.sessionStorage.setItem(storageKey, true); + } + } + + componentDidUpdate(prevProps, prevState) { + if (this.state.isModalOpen !== prevState.isModalOpen) { + if (this.state.isModalOpen) { + // add a class here to prevent scrolling on anything that is not the modal + document.body.classList.add('modal-open'); + } else { + // remove the class to allow the dashboard content to scroll + document.body.classList.remove('modal-open'); + } + } + } + + componentWillUnmount() { + // remove the class to allow the dashboard content to scroll + document.body.classList.remove('modal-open'); + document.removeEventListener('mousedown', this.handleClick, false); + } + + handleClick(e) { + if (this.modalRef && this.modalRef.contains(e.target)) { + // click is inside modal, don't close it + return; + } + + this.closeModal(); + } + + closeModal() { + this.setState({ + isModalOpen: false, + }); + } + + openModal() { + this.setState({ + isModalOpen: true, + }); + } + + getLearnerPortalUrl() { + const baseUrlWithSlug = `${this.props.enterpriseLearnerPortalBaseUrl}/${this.props.enterpriseCustomerSlug}`; + return `${baseUrlWithSlug}?utm_source=lms_dashboard_modal`; + } + + render() { + if (!this.state.isModalOpen) { + return null; + } + + return ( +
+ +
{ this.modalRef = node; }} + > +
+ {StringUtils.interpolate( + gettext('You have access to the {enterpriseName} dashboard'), + { + enterpriseName: this.props.enterpriseCustomerName, + } + )} +
+

+ {StringUtils.interpolate( + gettext('To access the courses available to you through {enterpriseName}, visit the {enterpriseName} dashboard.'), + { + enterpriseName: this.props.enterpriseCustomerName, + } + )} +

+
+ + + + {gettext('Go to dashboard')} + + +
+
+
+
+ ); + } +} + +export { EnterpriseLearnerPortalModal }; diff --git a/openedx/features/enterprise_support/api.py b/openedx/features/enterprise_support/api.py index f9a2934fb7..ba53e77885 100644 --- a/openedx/features/enterprise_support/api.py +++ b/openedx/features/enterprise_support/api.py @@ -787,13 +787,12 @@ def get_enterprise_learner_data_from_db(user): @enterprise_is_enabled() -def get_enterprise_learner_portal_enabled_message(request): +def enterprise_customer_from_session_or_learner_data(request): """ - Returns message to be displayed in dashboard if the user is linked to an Enterprise with the Learner Portal enabled. + Returns an Enterprise Customer for the authenticated user. - Note: request.session[ENTERPRISE_CUSTOMER_KEY_NAME] will be used in case the user is linked to - multiple Enterprises. Otherwise, it won't exist and the Enterprise Learner data - will be used. If that doesn't exist return None. + Retrieves customer from session by default. If _CACHE_MISS, retrieve customer using + learner data from the DB and add customer data to the session. Args: request: request made to the LMS dashboard @@ -812,31 +811,73 @@ def get_enterprise_learner_portal_enabled_message(request): add_enterprise_customer_to_session(request, enterprise_customer) if enterprise_customer: cache_enterprise(enterprise_customer) + return enterprise_customer + +@enterprise_is_enabled() +def get_enterprise_learner_portal_enabled_message(enterprise_customer): + """ + Returns message to be displayed in dashboard if the user is linked to an Enterprise with the Learner Portal enabled. + Note: request.session[ENTERPRISE_CUSTOMER_KEY_NAME] will be used in case the user is linked to + multiple Enterprises. Otherwise, it won't exist and the Enterprise Learner data + will be used. If that doesn't exist return None. + Args: + enterprise_customer: EnterpriseCustomer object + """ if not enterprise_customer: return None - if enterprise_customer.get('enable_learner_portal', False): - learner_portal_url = settings.ENTERPRISE_LEARNER_PORTAL_BASE_URL + '/' + enterprise_customer['slug'] - return Text(_( - "Your organization {bold_start}{enterprise_customer_name}{bold_end} uses a custom dashboard for learning. " - "{link_start}Click here {screen_reader_start}for your {enterprise_customer_name} dashboard," - "{screen_reader_end}{link_end} to continue in that experience." - )).format( - enterprise_customer_name=enterprise_customer['name'], - link_start=HTML("").format( - learner_portal_url=learner_portal_url, - ), - link_end=HTML(""), - bold_start=HTML(""), - bold_end=HTML(""), - screen_reader_start=HTML(""), - screen_reader_end=HTML(""), - ) - else: + if not enterprise_customer.get('enable_learner_portal', False): return None + learner_portal_url = "{base_url}/{slug}?utm_source=lms_dashboard_banner".format( + base_url=settings.ENTERPRISE_LEARNER_PORTAL_BASE_URL, + slug=enterprise_customer['slug'] + ) + return Text(_( + "You have access to the {bold_start}{enterprise_name}{bold_end} dashboard. " + "To access the courses available to you through {enterprise_name}, " + "{link_start}visit the {enterprise_name} dashboard{link_end}." + )).format( + enterprise_name=enterprise_customer['name'], + bold_start=HTML(""), + bold_end=HTML(""), + link_start=HTML(f""), + link_end=HTML(""), + ) + + +@enterprise_is_enabled(otherwise={}) +def get_enterprise_learner_portal_context(request): + """ + Determines a selected enterprise customer from session or learner data from the DB. + + Arguments: + request: A request object. + + Returns: + dict: A dictionary representing the necessary metadata and messaging about an Enterprise Learner Portal, + used in the dashboard.html template. + """ + context = {} + enterprise_customer = enterprise_customer_from_session_or_learner_data(request) + if not enterprise_customer: + return context + + enterprise_learner_portal_enabled_message = get_enterprise_learner_portal_enabled_message(enterprise_customer) + context.update({ + 'enterprise_customer_name': enterprise_customer.get('name'), + 'enterprise_customer_slug': enterprise_customer.get('slug'), + 'enterprise_customer_learner_portal_enabled': enterprise_customer.get('enable_learner_portal', False), + 'enterprise_customer_uuid': enterprise_customer.get('uuid'), + 'enterprise_learner_portal_base_url': settings.ENTERPRISE_LEARNER_PORTAL_BASE_URL, + 'enterprise_learner_portal_enabled_message': enterprise_learner_portal_enabled_message, + }) + return context + + +@enterprise_is_enabled() def get_consent_notification_data(enterprise_customer): """ Returns the consent notification data from DataSharingConsentPage modal diff --git a/openedx/features/enterprise_support/tests/test_api.py b/openedx/features/enterprise_support/tests/test_api.py index 4307d3be3c..fcb431b54c 100644 --- a/openedx/features/enterprise_support/tests/test_api.py +++ b/openedx/features/enterprise_support/tests/test_api.py @@ -35,6 +35,7 @@ from openedx.features.enterprise_support.api import ( data_sharing_consent_required, enterprise_customer_for_request, enterprise_customer_from_api, + enterprise_customer_from_session_or_learner_data, enterprise_customer_uuid_for_request, enterprise_enabled, get_consent_notification_data, @@ -777,26 +778,26 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase): ] @mock.patch('openedx.features.enterprise_support.api.get_enterprise_learner_data_from_db') - def test_enterprise_learner_portal_message_cache_miss_no_customer(self, mock_learner_data_from_db): + def test_enterprise_customer_from_session_or_db_cache_miss_no_customer(self, mock_learner_data_from_db): """ When no customer data exists in the request session _and_ - no customer is associated with the requesting user, then ``get_enterprise_learner_portal_enabled_message()`` + no customer is associated with the requesting user, then ``enterprise_customer_from_session_or_learner_data()`` should return None. """ mock_request = mock.Mock(session={}) mock_learner_data_from_db.return_value = None - actual_result = get_enterprise_learner_portal_enabled_message(mock_request) + actual_result = enterprise_customer_from_session_or_learner_data(mock_request) assert actual_result is None mock_learner_data_from_db.assert_called_once_with(mock_request.user) @mock.patch('openedx.features.enterprise_support.api.get_enterprise_learner_data_from_db') @override_settings(ENTERPRISE_LEARNER_PORTAL_BASE_URL='http://localhost') - def test_enterprise_learner_portal_message_cache_miss_customer_exists(self, mock_learner_data_from_db): + def test_enterprise_customer_from_session_or_db_cache_miss_customer_exists(self, mock_learner_data_from_db): """ When no customer data exists in the request session but a - customer is associated with the requesting user, then ``get_enterprise_learner_portal_enabled_message()`` - should return an appropriate message for that customer. + customer is associated with the requesting user, then ``enterprise_customer_from_session_or_learner_data()`` + should return the customer metadata. """ mock_request = mock.Mock(session={}) mock_enterprise_customer = { @@ -811,37 +812,33 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase): }, ] - actual_result = get_enterprise_learner_portal_enabled_message(mock_request) - assert 'custom dashboard for learning' in actual_result - assert 'Best Corp' in actual_result + actual_result = enterprise_customer_from_session_or_learner_data(mock_request) + assert actual_result['uuid'] == mock_enterprise_customer['uuid'] mock_learner_data_from_db.assert_called_once_with(mock_request.user) # assert we cached the enterprise customer data in the request session after fetching it assert mock_request.session.get(ENTERPRISE_CUSTOMER_KEY_NAME) == mock_enterprise_customer @mock.patch('openedx.features.enterprise_support.api.get_enterprise_learner_data_from_db') - def test_enterprise_learner_portal_message_cache_hit_no_customer(self, mock_learner_data_from_db): + def test_enterprise_customer_from_session_or_db_cache_hit_no_customer(self, mock_learner_data_from_db): """ When customer data exists in the request session but it's null/empty, - then ``get_enterprise_learner_portal_enabled_message()`` should return None. + then ``enterprise_customer_from_session_or_learner_data()`` should return None. """ mock_request = mock.Mock(session={ ENTERPRISE_CUSTOMER_KEY_NAME: None, }) - actual_result = get_enterprise_learner_portal_enabled_message(mock_request) + actual_result = enterprise_customer_from_session_or_learner_data(mock_request) assert actual_result is None assert not mock_learner_data_from_db.called @ddt.data(True, False) - @mock.patch('openedx.features.enterprise_support.api.get_enterprise_learner_data_from_db') @override_settings(ENTERPRISE_LEARNER_PORTAL_BASE_URL='http://localhost') - def test_enterprise_learner_portal_message_cache_hit_customer_exists( - self, enable_learner_portal, mock_learner_data_from_db - ): + def test_enterprise_learner_portal_message_customer_exists(self, enable_learner_portal): """ - When customer data exists in the request session and it's a non-empty customer, - then ``get_enterprise_learner_portal_enabled_message()`` should return - an appropriate message for that customer. + When an enterprise customer exists with learner portal enabled, then + ``get_enterprise_learner_portal_enabled_message()`` should return an appropriate message + for that customer. """ mock_enterprise_customer = { 'uuid': 'some-uuid', @@ -849,17 +846,21 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase): 'enable_learner_portal': enable_learner_portal, 'slug': 'best-corp', } - mock_request = mock.Mock(session={ - ENTERPRISE_CUSTOMER_KEY_NAME: mock_enterprise_customer, - }) - actual_result = get_enterprise_learner_portal_enabled_message(mock_request) + actual_result = get_enterprise_learner_portal_enabled_message(mock_enterprise_customer) if not enable_learner_portal: assert actual_result is None else: - assert 'custom dashboard for learning' in actual_result + assert 'To access the courses available to you through' in actual_result assert 'Best Corp' in actual_result - assert not mock_learner_data_from_db.called + + def test_enterprise_learner_portal_message_no_customer(self): + """ + When an enterprise customer does not exists, then + ``get_enterprise_learner_portal_enabled_message()`` should return None. + """ + actual_result = get_enterprise_learner_portal_enabled_message(None) + assert actual_result is None @mock.patch('openedx.features.enterprise_support.api.get_partial_pipeline', return_value=None) def test_customer_uuid_for_request_sso_provider_id_customer_exists(self, mock_partial_pipeline): diff --git a/webpack.common.config.js b/webpack.common.config.js index c7d7a82118..0f76269a88 100644 --- a/webpack.common.config.js +++ b/webpack.common.config.js @@ -96,6 +96,7 @@ module.exports = Merge.smart({ DemographicsCollectionBanner: './lms/static/js/demographics_collection/DemographicsCollectionBanner.jsx', DemographicsCollectionModal: './lms/static/js/demographics_collection/DemographicsCollectionModal.jsx', AxiosJwtTokenService: './lms/static/js/jwt_auth/AxiosJwtTokenService.js', + EnterpriseLearnerPortalModal: './lms/static/js/learner_dashboard/EnterpriseLearnerPortalModal.jsx', // Learner Dashboard EntitlementFactory: './lms/static/js/learner_dashboard/course_entitlement_factory.js',