diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 698d5b555d641..7d48955f7d6dc 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -188,6 +188,7 @@ "I18N_ASSESSMENT_INSTRUCTION_NEW_ATTEMPT": "In that case, you can start the assessment again later, which will begin a new attempt.", "I18N_ASSESSMENT_INSTRUCTION_ONE_QUESTION_AT_A_TIME": "You'll see one question at a time.", "I18N_ASSESSMENT_INSTRUCTION_PROGRESS_NOT_SAVED": "If your internet connection drops or you exit the assessment before submitting, your progress will not be saved.", + "I18N_ASSESSMENT_INSTRUCTION_QUESTION_COUNT": "The assessment contains <[questions]> questions.", "I18N_ASSESSMENT_INSTRUCTION_REVIEW_ANSWERS": "You can review and change your answers at any time before submitting.", "I18N_ASSESSMENT_INSTRUCTION_TIME_LIMIT": "You'll have <[minutes]> minutes to complete the assessment.", "I18N_ASSESSMENT_INSTRUCTION_UNANSWERED_MARKED_INCORRECT": "Unanswered questions will be marked as Incorrect answers.", @@ -1767,8 +1768,8 @@ "I18N_VIEW_HINT_WITH_INDEX_BUTTON_TEXT": "View hint <[index]>", "I18N_VIEW_SOLUTION_BUTTON_TEXT": "View Solution", "I18N_VOLUNTEER_PAGE_BREADCRUMB": "Volunteer", - "I18N_VOLUNTEER_PAGE_FOOTER": "If you're interested in helping out, let us know your skills and interests and a team lead will reach out to you shortly!", - "I18N_VOLUNTEER_PAGE_SECTION_ONE_BUTTON": "Volunteer with Oppia", + "I18N_VOLUNTEER_PAGE_FOOTER": "Want to get involved? Check out our Idealist page below to see where our different teams need help, and apply for the specific role that matches your skills and interests!", + "I18N_VOLUNTEER_PAGE_SECTION_ONE_BUTTON": "Explore Volunteer Openings", "I18N_VOLUNTEER_PAGE_SECTION_ONE_CONTENT": "Improve access to education for millions of students. It doesn't matter where you come from, what language you speak, or how young or old you are - all are welcome!", "I18N_VOLUNTEER_PAGE_SECTION_ONE_HEADING": "Volunteer to make a difference", "I18N_VOLUNTEER_PAGE_SKILLS_ART_AND_DESIGN_SET1_HEADING": "Producing graphics, and defining design initiatives (UX/UI)", diff --git a/assets/i18n/qqq.json b/assets/i18n/qqq.json index c6cc3a0b81e62..ffd3f9df7930c 100644 --- a/assets/i18n/qqq.json +++ b/assets/i18n/qqq.json @@ -1767,8 +1767,8 @@ "I18N_VIEW_HINT_WITH_INDEX_BUTTON_TEXT": "Label for the button to view a specific hint, showing its sequence number.", "I18N_VIEW_SOLUTION_BUTTON_TEXT": "Label for the button that shows the solution when clicked.", "I18N_VOLUNTEER_PAGE_BREADCRUMB": "Text displayed in the Volunteer page. - Text shown in the top left corner of the nav bar.", - "I18N_VOLUNTEER_PAGE_FOOTER": "Text in the footer section of the volunteer page. It provides github repo link.", - "I18N_VOLUNTEER_PAGE_SECTION_ONE_BUTTON": "Fill out interest form button in section one.", + "I18N_VOLUNTEER_PAGE_FOOTER": "Text in the footer section of the volunteer page. It invites the user to check out the Oppia Idealist page for volunteering opportunities.", + "I18N_VOLUNTEER_PAGE_SECTION_ONE_BUTTON": "Button that links to the Oppia Idealist page with volunteering opportunities.", "I18N_VOLUNTEER_PAGE_SECTION_ONE_CONTENT": "Text content under the title in section one. -First paragraph", "I18N_VOLUNTEER_PAGE_SECTION_ONE_HEADING": "Title of section one in volunteer page.", "I18N_VOLUNTEER_PAGE_SKILLS_ART_AND_DESIGN_SET1_HEADING": "Heading for the art and design tab skill set-1 in the volunteer section of volunteer page.", diff --git a/core/controllers/acl_decorators.py b/core/controllers/acl_decorators.py index aefbeaeb051aa..661e4ddf27595 100644 --- a/core/controllers/acl_decorators.py +++ b/core/controllers/acl_decorators.py @@ -2884,6 +2884,63 @@ def test_login( return test_login +def can_access_certificate_assessment_attempt_result( + handler: Callable[..., _GenericHandlerFunctionReturnType], +) -> Callable[..., _GenericHandlerFunctionReturnType]: + """Decorator that checks whether the user can access a certificate + assessment attempt result. + + Args: + handler: function. The function to be decorated. + + Returns: + function. The newly decorated function that now also checks + whether the user can access the given attempt. + """ + + # Here we use type Any because this method can accept arbitrary number of + # arguments with different types. + @functools.wraps(handler) + def test_can_access_certificate_assessment_attempt_result( + self: _SelfBaseHandlerType, attempt_id: str, **kwargs: Any + ) -> _GenericHandlerFunctionReturnType: + """Checks whether the user can access the given attempt. + + Args: + attempt_id: str. The ID of the certificate assessment attempt. + **kwargs: *. Keyword arguments. + + Returns: + *. The return value of the decorated function. + + Raises: + NotLoggedInException. The user is not logged in. + NotFoundException. The attempt does not exist. + UnauthorizedUserException. The user does not own the attempt. + """ + if not self.user_id: + raise base.UserFacingExceptions.NotLoggedInException + + try: + attempt = certificate_assessment_services.get_certificate_attempt( + attempt_id + ) + except ( + certificate_assessment_services.CertificateAssessmentAttemptNotFoundException + ) as e: + raise self.NotFoundException(str(e)) from e + + if attempt.learner_id != self.user_id: + raise self.UnauthorizedUserException( + 'You do not have permission to access this certificate ' + 'assessment attempt result.' + ) + + return handler(self, attempt_id, **kwargs) + + return test_can_access_certificate_assessment_attempt_result + + def can_edit_topic( handler: Callable[..., _GenericHandlerFunctionReturnType], ) -> Callable[..., _GenericHandlerFunctionReturnType]: diff --git a/core/controllers/acl_decorators_test.py b/core/controllers/acl_decorators_test.py index 0cf83f27b73d7..a129e99ad2721 100644 --- a/core/controllers/acl_decorators_test.py +++ b/core/controllers/acl_decorators_test.py @@ -60,13 +60,17 @@ certificate_assessment_offering_models, datastore_services, secrets_services, + suggestion_models, ) + datastore_services = models.Registry.import_datastore_services() secrets_services = models.Registry.import_secrets_services() -(certificate_assessment_offering_models,) = models.Registry.import_models( - [models.Names.CERTIFICATE_ASSESSMENT_OFFERING] +( + certificate_assessment_offering_models, + suggestion_models, +) = models.Registry.import_models( + [models.Names.CERTIFICATE_ASSESSMENT_OFFERING, models.Names.SUGGESTION] ) -(suggestion_models,) = models.Registry.import_models([models.Names.SUGGESTION]) class OpenAccessDecoratorTests(test_utils.GenericTestBase): @@ -726,6 +730,103 @@ def test_guest_user_is_redirected_to_homepage(self) -> None: self.assertEqual('http://localhost/', response.headers['location']) +class CertificateAssessmentAttemptResultAccessDecoratorTests( + test_utils.GenericTestBase +): + """Tests for can_access_certificate_assessment_attempt_result decorator.""" + + username = 'user' + user_email = 'user@example.com' + other_username = 'otheruser' + other_email = 'otheruser@example.com' + + class MockHandler(base.BaseHandler[Dict[str, str], Dict[str, str]]): + GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON + URL_PATH_ARGS_SCHEMAS = { + 'attempt_id': {'schema': {'type': 'basestring'}} + } + HANDLER_ARGS_SCHEMAS: Dict[str, Dict[str, str]] = {'GET': {}} + + @acl_decorators.can_access_certificate_assessment_attempt_result + def get(self, attempt_id: str) -> None: + self.render_json({'attempt_id': attempt_id}) + + def setUp(self) -> None: + super().setUp() + self.signup(self.user_email, self.username) + self.signup(self.other_email, self.other_username) + self.user_id = self.get_user_id_from_email(self.user_email) + + self.mock_testapp = webtest.TestApp( + webapp2.WSGIApplication( + [ + webapp2.Route( + '/mock_certificate_attempt/', + self.MockHandler, + ) + ], + debug=feconf.DEBUG, + ) + ) + self.attempt = certificate_assessment_offering_models.CertificateAssessmentAttemptModel.create( + learner_id=self.user_id, + total_score=80.0, + attempt_index=1, + attempt_data={ + 'topic_id_101': { + 'total_related_questions': 5, + 'total_correct_questions': 3, + } + }, + version_data={ + 'certificate_id': 'cert_abc123', + 'certificate_version': 1, + 'topic_versions': {'topic_id_101': 2}, + 'question_versions': {'question_id_1': 1}, + 'question_topic_links': {'question_id_1': ['topic_id_101']}, + }, + started_at=datetime.datetime(2026, 7, 18), + finished_at=None, + is_submitted=True, + ) + + def test_attempt_owner_can_access_attempt(self) -> None: + self.login(self.user_email) + with self.swap(self, 'testapp', self.mock_testapp): + response = self.get_json( + '/mock_certificate_attempt/%s' % self.attempt.id + ) + self.assertEqual(response['attempt_id'], self.attempt.id) + self.logout() + + def test_other_user_cannot_access_attempt(self) -> None: + self.login(self.other_email) + with self.swap(self, 'testapp', self.mock_testapp): + self.get_json( + '/mock_certificate_attempt/%s' % self.attempt.id, + expected_status_int=401, + ) + self.logout() + + def test_guest_user_cannot_access_attempt(self) -> None: + with self.swap(self, 'testapp', self.mock_testapp): + response = self.get_json( + '/mock_certificate_attempt/%s' % self.attempt.id, + expected_status_int=401, + ) + error_msg = 'You must be logged in to access this resource.' + self.assertEqual(response['error'], error_msg) + + def test_missing_attempt_returns_404(self) -> None: + self.login(self.user_email) + with self.swap(self, 'testapp', self.mock_testapp): + self.get_json( + '/mock_certificate_attempt/missing_attempt_id', + expected_status_int=404, + ) + self.logout() + + class PlayExplorationDecoratorTests(test_utils.GenericTestBase): """Tests for play exploration decorator.""" diff --git a/core/controllers/certificate_assessment.py b/core/controllers/certificate_assessment.py index 5de24ebf9db7e..b25b5ad0cdffc 100644 --- a/core/controllers/certificate_assessment.py +++ b/core/controllers/certificate_assessment.py @@ -16,14 +16,28 @@ from __future__ import annotations +import datetime + from core import feconf, utils from core.controllers import acl_decorators, base from core.controllers import domain_objects_validator as validation_method -from core.domain import certificate_assessment_services +from core.domain import certificate_assessment_services, topic_fetchers from typing import Any, Dict, List, TypedDict +def _format_utc_datetime(value: datetime.datetime) -> str: + """Formats a naive UTC datetime as an ISO-8601 string with a 'Z' suffix. + + Args: + value: datetime.datetime. The naive UTC datetime to format. + + Returns: + str. The ISO-8601 string representation of the datetime. + """ + return value.isoformat() + 'Z' + + class CertificateAssessmentOfferingTopicDict(TypedDict): """Dict representation of a certificate assessment topic.""" @@ -536,7 +550,7 @@ class CertificateAssessmentResultHandler( CertificateAssessmentResultHandlerNormalizedRequestDict, ] ): - """Stub handler for fetching a certificate assessment result.""" + """Handler for fetching a certificate assessment result.""" GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON URL_PATH_ARGS_SCHEMAS = { @@ -544,23 +558,51 @@ class CertificateAssessmentResultHandler( } HANDLER_ARGS_SCHEMAS = {'GET': {}} - # TODO(#24717-2.14): Replace open_access with - # can_access_certificate_assessment_attempt once real result - # fetching logic exists. - @acl_decorators.open_access - def get(self, attempt_id: str) -> None: # pylint: disable=unused-argument - """Returns a hardcoded result payload.""" + @acl_decorators.can_access_certificate_assessment_attempt_result + def get(self, attempt_id: str) -> None: + """Returns the result for the given attempt. + + Args: + attempt_id: str. The ID of the certificate assessment attempt. + """ + attempt = certificate_assessment_services.get_certificate_attempt( + attempt_id + ) + try: + certificate_offering = certificate_assessment_services.get_certificate_assessment_offering( + attempt.version_data['certificate_id'] + ) + except ( + certificate_assessment_services.CertificateAssessmentOfferingNotFoundException + ) as e: + raise self.NotFoundException(str(e)) from e + topics = topic_fetchers.get_topics_by_ids( + list(attempt.attempt_data.keys()) + ) + topic_names_by_id = { + topic.id: topic.name for topic in topics if topic is not None + } + # Here we use object because attempt_data values are heterogeneous + # payloads mixing strings and integers. + attempt_data: Dict[str, Dict[str, object]] = {} + for topic_id, topic_stats in attempt.attempt_data.items(): + attempt_data[topic_id] = { + 'topic_name': topic_names_by_id.get(topic_id, topic_id), + 'total_related_questions': topic_stats[ + 'total_related_questions' + ], + 'total_correct_questions': topic_stats[ + 'total_correct_questions' + ], + } self.render_json( { - 'title': 'Everyday Arithmetic & Number Confidence', - 'total_score': 80, - 'attempt_data': { - 'dummy_topic_id': { - 'total_related_questions': 5, - 'total_correct_questions': 4, - }, - }, - 'is_submitted': True, + 'certificate_id': certificate_offering.certificate_id, + 'title': certificate_offering.title, + 'total_score': attempt.total_score, + 'time_taken_in_minutes': attempt.get_time_taken_in_minutes(), + 'attempt_data': attempt_data, + 'is_submitted': attempt.is_submitted, } ) @@ -568,30 +610,42 @@ def get(self, attempt_id: str) -> None: # pylint: disable=unused-argument class CertificateAssessmentAttemptsHandler( base.BaseHandler[Dict[str, str], Dict[str, str]] ): - """Stub handler for listing a learner's certificate attempts.""" + """Handler for listing a learner's certificate attempts.""" GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON URL_PATH_ARGS_SCHEMAS: Dict[str, str] = {} HANDLER_ARGS_SCHEMAS = {'GET': {}} - # TODO(#24717-2.14): Replace open_access with - # require_user_id_else_redirect_to_homepage once learner_id is - # pulled from the session for the real implementation. - @acl_decorators.open_access + @acl_decorators.require_user_id_else_redirect_to_homepage def get(self) -> None: - """Returns a hardcoded list of attempts.""" - self.render_json( - { - 'attempts': [ - { - 'attempt_id': 'dummy_attempt_id', - 'classroom_id': 'dummy_classroom_id', - 'title': 'Everyday Arithmetic & Number Confidence', - 'total_score': 80, - 'attempt_index': 1, - 'started_at': '2026-07-18T00:00:00Z', - 'is_submitted': True, - } - ] - } + """Returns the learner's certificate assessment attempts.""" + assert self.user_id is not None + attempts = certificate_assessment_services.get_certificate_attempts( + self.user_id + ) + certificate_ids = list( + {attempt.version_data['certificate_id'] for attempt in attempts} ) + offerings_by_id = certificate_assessment_services.get_certificate_assessment_offerings_by_ids( + certificate_ids + ) + # Here we use object because the attempt summary values are + # heterogeneous JSON payloads (strings, floats, integers and booleans). + attempt_summaries: List[Dict[str, object]] = [] + for attempt in attempts: + certificate_id = attempt.version_data['certificate_id'] + if certificate_id not in offerings_by_id: + continue + certificate_offering = offerings_by_id[certificate_id] + attempt_summaries.append( + { + 'attempt_id': attempt.attempt_id, + 'classroom_id': certificate_offering.classroom_id, + 'title': certificate_offering.title, + 'total_score': attempt.total_score, + 'attempt_index': attempt.attempt_index, + 'started_at': _format_utc_datetime(attempt.started_at), + 'is_submitted': attempt.is_submitted, + } + ) + self.render_json({'attempts': attempt_summaries}) diff --git a/core/controllers/certificate_assessment_test.py b/core/controllers/certificate_assessment_test.py index b3c27e8f30af8..2584838de2b35 100644 --- a/core/controllers/certificate_assessment_test.py +++ b/core/controllers/certificate_assessment_test.py @@ -22,15 +22,100 @@ from core import feconf, utils from core.controllers import certificate_assessment from core.domain import ( + certificate_assessment_domain, certificate_assessment_services, classroom_config_domain, classroom_config_services, topic_fetchers, ) +from core.platform import models from core.storage.certificate_assessment import gae_models from core.tests import test_utils -from typing import Dict, List, Union +from typing import Dict, List, Optional, Union + +MYPY = False +if MYPY: # pragma: no cover + from mypy_imports import certificate_assessment_offering_models + +(certificate_assessment_offering_models,) = models.Registry.import_models( + [models.Names.CERTIFICATE_ASSESSMENT_OFFERING] +) + + +def _create_attempt_model( + learner_id: str, + certificate_id: str, + total_score: float, + attempt_index: int, + started_at: Optional[datetime.datetime] = None, + finished_at: Optional[datetime.datetime] = None, + is_submitted: bool = True, +) -> certificate_assessment_offering_models.CertificateAssessmentAttemptModel: + """Creates and returns a certificate assessment attempt model. + + Args: + learner_id: str. The ID of the learner making the attempt. + certificate_id: str. The ID of the certificate offering the attempt + was generated for. + total_score: float. The total score achieved in the attempt. + attempt_index: int. The index of the attempt for the learner. + started_at: datetime.datetime|None. When the attempt was started. + finished_at: datetime.datetime|None. When the attempt was finished. + is_submitted: bool. Whether the attempt has been submitted. + + Returns: + CertificateAssessmentAttemptModel. The created attempt model. + """ + return certificate_assessment_offering_models.CertificateAssessmentAttemptModel.create( + learner_id=learner_id, + total_score=total_score, + attempt_index=attempt_index, + attempt_data={ + 'topic_place_values': { + 'total_related_questions': 5, + 'total_correct_questions': 4, + } + }, + version_data={ + 'certificate_id': certificate_id, + 'certificate_version': 1, + 'topic_versions': {'topic_place_values': 1}, + 'question_versions': {'dummy_question_id': 1}, + 'question_topic_links': { + 'dummy_question_id': ['topic_place_values'] + }, + }, + started_at=( + started_at + if started_at is not None + else datetime.datetime(2026, 7, 18) + ), + finished_at=finished_at, + is_submitted=is_submitted, + ) + + +def _create_certificate_offering() -> ( + certificate_assessment_domain.CertificateAssessmentOffering +): + """Creates and returns a certificate assessment offering for tests. + + Returns: + CertificateAssessmentOffering. The created certificate offering. + """ + return ( + certificate_assessment_services.create_certificate_assessment_offering( + title='Everyday Arithmetic & Number Confidence', + description='Covers place values, addition and subtraction.', + classroom_id='math_classroom_01', + topic_ids=['topic_place_values'], + total_questions=12, + time_limit_in_minutes=60, + demonstrates=['Understanding of whole numbers'], + async_status='Available', + ) + ) class CertificateAssessmentOfferingHandlerUnitTests(test_utils.GenericTestBase): @@ -470,6 +555,7 @@ def test_get_returns_certificate_offerings_for_classroom(self) -> None: finished_at = started_at + datetime.timedelta(minutes=5) gae_models.CertificateAssessmentAttemptModel.create( learner_id=learner_id, + certificate_id=certificate_ids[0]['certificate_id'], total_score=90.0, attempt_index=1, attempt_data={ @@ -536,19 +622,32 @@ def test_get_raises_not_logged_in_when_user_id_is_missing(self) -> None: class CertificateAssessmentResultHandlerTest(test_utils.GenericTestBase): """Tests class for CertificateAssessmentResultHandler.""" - def test_get_returns_hardcoded_result_payload(self) -> None: + def setUp(self) -> None: + super().setUp() + self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME) + self.learner_id = self.get_user_id_from_email(self.OWNER_EMAIL) + self.certificate_offering = _create_certificate_offering() + self.attempt = _create_attempt_model( + self.learner_id, self.certificate_offering.certificate_id, 80.0, 1 + ) + + def test_get_returns_real_result(self) -> None: + self.login(self.OWNER_EMAIL) response = self.get_json( feconf.CERTIFICATE_ASSESSMENT_RESULT_HANDLER.replace( - '', 'dummy_attempt_id' + '', self.attempt.id ) ) self.assertEqual( response, { + 'certificate_id': self.certificate_offering.certificate_id, 'title': 'Everyday Arithmetic & Number Confidence', - 'total_score': 80, + 'total_score': 80.0, + 'time_taken_in_minutes': None, 'attempt_data': { - 'dummy_topic_id': { + 'topic_place_values': { + 'topic_name': 'topic_place_values', 'total_related_questions': 5, 'total_correct_questions': 4, }, @@ -556,22 +655,196 @@ def test_get_returns_hardcoded_result_payload(self) -> None: 'is_submitted': True, }, ) + self.logout() + + def test_get_returns_time_taken_for_finished_attempt(self) -> None: + self.login(self.OWNER_EMAIL) + finished_attempt = _create_attempt_model( + self.learner_id, + self.certificate_offering.certificate_id, + 80.0, + 1, + started_at=datetime.datetime(2026, 7, 18, 10, 0), + finished_at=datetime.datetime(2026, 7, 18, 10, 35), + ) + response = self.get_json( + feconf.CERTIFICATE_ASSESSMENT_RESULT_HANDLER.replace( + '', finished_attempt.id + ) + ) + self.assertEqual(response['time_taken_in_minutes'], 35) + self.logout() + + def test_get_returns_topic_name_from_fetched_topic(self) -> None: + self.login(self.OWNER_EMAIL) + topic_id = topic_fetchers.get_new_topic_id() + self.save_new_topic( + topic_id, + self.OWNER_EMAIL, + name='Place Values', + abbreviated_name='place_values', + ) + attempt_with_topic = _create_attempt_model( + self.learner_id, + self.certificate_offering.certificate_id, + 80.0, + 2, + ) + attempt_with_topic.attempt_data = { + topic_id: { + 'total_related_questions': 5, + 'total_correct_questions': 4, + } + } + attempt_with_topic.update_timestamps() + attempt_with_topic.put() + response = self.get_json( + feconf.CERTIFICATE_ASSESSMENT_RESULT_HANDLER.replace( + '', attempt_with_topic.id + ) + ) + self.assertEqual( + response['attempt_data'][topic_id]['topic_name'], 'Place Values' + ) + self.logout() + + def test_get_returns_404_for_missing_attempt(self) -> None: + self.login(self.OWNER_EMAIL) + self.get_json( + feconf.CERTIFICATE_ASSESSMENT_RESULT_HANDLER.replace( + '', 'missing_attempt_id' + ), + expected_status_int=404, + ) + self.logout() + + def test_get_returns_404_for_missing_certificate_offering(self) -> None: + self.login(self.OWNER_EMAIL) + orphan_attempt = _create_attempt_model( + self.learner_id, 'missing_certificate_id', 80.0, 1 + ) + self.get_json( + feconf.CERTIFICATE_ASSESSMENT_RESULT_HANDLER.replace( + '', orphan_attempt.id + ), + expected_status_int=404, + ) + self.logout() + + def test_get_returns_401_for_another_users_attempt(self) -> None: + self.signup('otheruser@example.com', 'otheruser') + other_user_id = self.get_user_id_from_email('otheruser@example.com') + other_attempt = _create_attempt_model( + other_user_id, self.certificate_offering.certificate_id, 70.0, 1 + ) + self.login(self.OWNER_EMAIL) + self.get_json( + feconf.CERTIFICATE_ASSESSMENT_RESULT_HANDLER.replace( + '', other_attempt.id + ), + expected_status_int=401, + ) + self.logout() + + def test_get_returns_401_for_guest_user(self) -> None: + self.get_json( + feconf.CERTIFICATE_ASSESSMENT_RESULT_HANDLER.replace( + '', self.attempt.id + ), + expected_status_int=401, + ) class CertificateAssessmentAttemptsHandlerUnitTests(test_utils.GenericTestBase): """Tests class for CertificateAssessmentAttemptsHandler.""" - def test_get_returns_hardcoded_attempts_list(self) -> None: + def setUp(self) -> None: + super().setUp() + self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME) + self.learner_id = self.get_user_id_from_email(self.OWNER_EMAIL) + self.certificate_offering = _create_certificate_offering() + + def test_get_returns_real_attempts_history(self) -> None: + first_attempt = _create_attempt_model( + self.learner_id, self.certificate_offering.certificate_id, 80.0, 1 + ) + second_attempt = _create_attempt_model( + self.learner_id, + self.certificate_offering.certificate_id, + 90.0, + 2, + ) + self.login(self.OWNER_EMAIL) + response = self.get_json(feconf.CERTIFICATE_ASSESSMENT_ATTEMPTS_HANDLER) + self.assertEqual( + response, + { + 'attempts': [ + { + 'attempt_id': first_attempt.id, + 'classroom_id': 'math_classroom_01', + 'title': 'Everyday Arithmetic & Number Confidence', + 'total_score': 80.0, + 'attempt_index': 1, + 'started_at': '2026-07-18T00:00:00Z', + 'is_submitted': True, + }, + { + 'attempt_id': second_attempt.id, + 'classroom_id': 'math_classroom_01', + 'title': 'Everyday Arithmetic & Number Confidence', + 'total_score': 90.0, + 'attempt_index': 2, + 'started_at': '2026-07-18T00:00:00Z', + 'is_submitted': True, + }, + ] + }, + ) + self.logout() + + def test_get_returns_empty_attempts_for_learner_without_attempts( + self, + ) -> None: + self.login(self.OWNER_EMAIL) + response = self.get_json(feconf.CERTIFICATE_ASSESSMENT_ATTEMPTS_HANDLER) + self.assertEqual(response, {'attempts': []}) + self.logout() + + def test_get_skips_attempts_with_deleted_certificate_offering( + self, + ) -> None: + existing_attempt = _create_attempt_model( + self.learner_id, self.certificate_offering.certificate_id, 80.0, 1 + ) + deleted_offering = certificate_assessment_services.create_certificate_assessment_offering( + title='Geography Essentials', + description='Covers maps and spatial reasoning.', + classroom_id='geography_classroom_01', + topic_ids=['topic_place_values'], + total_questions=6, + time_limit_in_minutes=30, + demonstrates=['Map reading'], + async_status='Available', + ) + _create_attempt_model( + self.learner_id, deleted_offering.certificate_id, 90.0, 2 + ) + certificate_assessment_services.delete_certificate_assessment_offering( + deleted_offering.certificate_id + ) + + self.login(self.OWNER_EMAIL) response = self.get_json(feconf.CERTIFICATE_ASSESSMENT_ATTEMPTS_HANDLER) self.assertEqual( response, { 'attempts': [ { - 'attempt_id': 'dummy_attempt_id', - 'classroom_id': 'dummy_classroom_id', - 'title': ('Everyday Arithmetic & Number Confidence'), - 'total_score': 80, + 'attempt_id': existing_attempt.id, + 'classroom_id': 'math_classroom_01', + 'title': 'Everyday Arithmetic & Number Confidence', + 'total_score': 80.0, 'attempt_index': 1, 'started_at': '2026-07-18T00:00:00Z', 'is_submitted': True, @@ -579,6 +852,7 @@ def test_get_returns_hardcoded_attempts_list(self) -> None: ] }, ) + self.logout() class StartCertificateAssessmentHandlerUnitTests(test_utils.GenericTestBase): diff --git a/core/controllers/suggestion.py b/core/controllers/suggestion.py index 90ebe683ed248..dd1970b0577c0 100644 --- a/core/controllers/suggestion.py +++ b/core/controllers/suggestion.py @@ -251,9 +251,7 @@ def post(self) -> None: assert isinstance( suggestion, suggestion_registry.SuggestionTranslateContent ) - self._copy_images_from_target_exploration_content_to_translation( - suggestion - ) + self._copy_images_from_target_content_to_translation(suggestion) files = self.normalized_payload.get('files') new_image_filenames = ( @@ -301,19 +299,19 @@ def _save_new_images_added_in_translation( image_is_compressible, ) - def _copy_images_from_target_exploration_content_to_translation( + def _copy_images_from_target_content_to_translation( self, suggestion: suggestion_registry.SuggestionTranslateContent ) -> None: - """Creates copies of images from the suggestion's target exploration + """Creates copies of images from the suggestion's target content for the translation suggestion to use. Args: suggestion: SuggestionTranslateContent. The translation suggestion - to copy its target exploration's images to. + to copy its target content's images to. Raises: - Exception. An image in the target exploration's content is not a - saved asset belonging to the target exploration. + Exception. An image in the target entity's content is not a + saved asset belonging to the target entity. """ target_image_filenames = ( html_cleaner.get_image_filenames_from_html_strings( @@ -646,22 +644,32 @@ def put(self, target_id: str, suggestion_id: str) -> None: ) suggestion = suggestion_services.get_suggestion_by_id(suggestion_id) - target_entity_html_list = ( - suggestion.get_target_entity_html_strings() - ) - target_image_filenames = ( - html_cleaner.get_image_filenames_from_html_strings( - target_entity_html_list + # Only question suggestions copy images at this point, because they + # are stored under the question suggestion image context. Images in + # a translation suggestion are copied at submission time instead, + # by _copy_images_from_target_content_to_translation, which handles + # every target type. This mirrors the exploration action handler, + # which does not copy images on accept either. + if ( + suggestion.suggestion_type + == feconf.SUGGESTION_TYPE_ADD_QUESTION + ): + target_entity_html_list = ( + suggestion.get_target_entity_html_strings() + ) + target_image_filenames = ( + html_cleaner.get_image_filenames_from_html_strings( + target_entity_html_list + ) ) - ) - fs_services.copy_images( - suggestion.target_type, - suggestion.target_id, - feconf.IMAGE_CONTEXT_QUESTION_SUGGESTIONS, - suggestion.target_id, - target_image_filenames, - ) + fs_services.copy_images( + suggestion.target_type, + suggestion.target_id, + feconf.IMAGE_CONTEXT_QUESTION_SUGGESTIONS, + suggestion.target_id, + target_image_filenames, + ) else: assert action == constants.ACTION_REJECT_SUGGESTION suggestion_services.reject_suggestion( @@ -673,6 +681,10 @@ def put(self, target_id: str, suggestion_id: str) -> None: suggestion = suggestion_services.get_suggestion_by_id(suggestion_id) if suggestion.suggestion_type == feconf.SUGGESTION_TYPE_ADD_QUESTION: suggestion_services.update_question_review_stats(suggestion) + elif suggestion.suggestion_type == ( + feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT + ): + suggestion_services.update_translation_review_stats(suggestion) self.render_json(self.values) diff --git a/core/controllers/suggestion_test.py b/core/controllers/suggestion_test.py index 7622107a36e62..ac9f77e3d9877 100644 --- a/core/controllers/suggestion_test.py +++ b/core/controllers/suggestion_test.py @@ -21,7 +21,7 @@ import base64 import os -from core import feature_flag_list, feconf +from core import feature_flag_list, feconf, utils from core.constants import constants from core.domain import ( exp_domain, @@ -2734,6 +2734,129 @@ def test_accept_question_suggestion(self) -> None: self.assertEqual(last_message.text, 'This looks good!') self.logout() + def test_post_and_accept_skill_translation_suggestion(self) -> None: + skill_id = skill_services.get_new_skill_id() + self.save_new_skill( + skill_id, self.admin_id, description='Skill description' + ) + + self.login(self.AUTHOR_EMAIL) + csrf_token = self.get_new_csrf_token() + self.post_json( + '%s/' % feconf.SUGGESTION_URL_PREFIX, + { + 'suggestion_type': feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + 'target_type': feconf.ENTITY_TYPE_SKILL, + 'target_id': skill_id, + 'target_version_at_submission': 1, + 'change_cmd': { + 'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION, + 'state_name': constants.DEFAULT_SUGGESTION_STATE_NAME, + 'content_id': feconf.SKILL_DESCRIPTION_CONTENT_ID, + 'language_code': 'hi', + 'content_html': 'Skill description', + 'translation_html': 'Skill description in Hindi', + 'data_format': 'unicode', + }, + 'description': 'Skill translation suggestion', + }, + csrf_token=csrf_token, + ) + self.logout() + + suggestion = suggestion_services.query_suggestions( + [('target_id', skill_id)] + )[0] + self.assertEqual(suggestion.target_type, feconf.ENTITY_TYPE_SKILL) + self.assertEqual(suggestion.status, suggestion_models.STATUS_IN_REVIEW) + + self.login(self.CURRICULUM_ADMIN_EMAIL) + csrf_token = self.get_new_csrf_token() + with self.swap( + opportunity_services, + 'update_translation_opportunity_with_accepted_suggestion', + lambda *args: None, + ): + self.put_json( + '%s/skill/%s/%s' + % ( + feconf.SUGGESTION_ACTION_URL_PREFIX, + skill_id, + suggestion.suggestion_id, + ), + { + 'action': 'accept', + 'review_message': 'Accepted skill translation!', + }, + csrf_token=csrf_token, + ) + + updated_suggestion = suggestion_services.get_suggestion_by_id( + suggestion.suggestion_id + ) + self.assertEqual( + updated_suggestion.status, suggestion_models.STATUS_ACCEPTED + ) + self.logout() + + def test_reject_skill_translation_suggestion(self) -> None: + skill_id = skill_services.get_new_skill_id() + self.save_new_skill( + skill_id, self.admin_id, description='Skill description' + ) + + self.login(self.AUTHOR_EMAIL) + csrf_token = self.get_new_csrf_token() + self.post_json( + '%s/' % feconf.SUGGESTION_URL_PREFIX, + { + 'suggestion_type': feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + 'target_type': feconf.ENTITY_TYPE_SKILL, + 'target_id': skill_id, + 'target_version_at_submission': 1, + 'change_cmd': { + 'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION, + 'state_name': constants.DEFAULT_SUGGESTION_STATE_NAME, + 'content_id': feconf.SKILL_DESCRIPTION_CONTENT_ID, + 'language_code': 'hi', + 'content_html': 'Skill description', + 'translation_html': 'Skill description in Hindi', + 'data_format': 'unicode', + }, + 'description': 'Skill translation suggestion', + }, + csrf_token=csrf_token, + ) + self.logout() + + suggestion = suggestion_services.query_suggestions( + [('target_id', skill_id)] + )[0] + + self.login(self.CURRICULUM_ADMIN_EMAIL) + csrf_token = self.get_new_csrf_token() + self.put_json( + '%s/skill/%s/%s' + % ( + feconf.SUGGESTION_ACTION_URL_PREFIX, + skill_id, + suggestion.suggestion_id, + ), + { + 'action': 'reject', + 'review_message': 'Rejected skill translation', + }, + csrf_token=csrf_token, + ) + + updated_suggestion = suggestion_services.get_suggestion_by_id( + suggestion.suggestion_id + ) + self.assertEqual( + updated_suggestion.status, suggestion_models.STATUS_REJECTED + ) + self.logout() + def test_accept_question_suggestion_with_image_region_interactions( self, ) -> None: @@ -3364,16 +3487,33 @@ def test_reject_suggestion_to_skill_with_different_suggestion_type( suggestion_id = suggestion_to_accept['suggestion_id'] suggestion = suggestion_services.get_suggestion_by_id(suggestion_id) - # Create a mock suggestion with a different suggestion_type. class MockSuggestion: def __init__( - self, original_suggestion: suggestion_registry.BaseSuggestion + self, + original_suggestion: suggestion_registry.BaseSuggestion, + reviewer_id: str, ): self.suggestion_type = feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT self.target_id = original_suggestion.target_id self.target_type = original_suggestion.target_type + self.author_id = original_suggestion.author_id + self.final_reviewer_id = reviewer_id + self.status = suggestion_models.STATUS_REJECTED + self.edited_by_reviewer = False + self.last_updated = utils.get_current_utc_datetime() + self.change_cmd = exp_domain.AddWrittenTranslationCmd( + { + 'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION, + 'state_name': constants.DEFAULT_SUGGESTION_STATE_NAME, + 'content_id': feconf.SKILL_DESCRIPTION_CONTENT_ID, + 'language_code': 'hi', + 'content_html': 'Skill description', + 'translation_html': 'Skill description in Hindi', + 'data_format': 'unicode', + } + ) - mock_suggestion = MockSuggestion(suggestion) + mock_suggestion = MockSuggestion(suggestion, self.admin_id) # Swap suggestion_services.get_suggestion_by_id to return our mock suggestion. swap_get_suggestion = self.swap( @@ -3397,6 +3537,132 @@ def __init__( self.logout() + def test_post_and_accept_skill_translation_suggestion(self) -> None: + self.login(self.AUTHOR_EMAIL) + csrf_token = self.get_new_csrf_token() + + self.post_json( + '%s/' % feconf.SUGGESTION_URL_PREFIX, + { + 'suggestion_type': feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + 'target_type': feconf.ENTITY_TYPE_SKILL, + 'target_id': self.skill_id, + 'target_version_at_submission': 1, + 'change_cmd': { + 'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION, + 'state_name': constants.DEFAULT_SUGGESTION_STATE_NAME, + 'content_id': feconf.SKILL_DESCRIPTION_CONTENT_ID, + 'language_code': 'hi', + 'content_html': 'Description', + 'translation_html': '

Skill description in Hindi

', + 'data_format': 'html', + }, + 'description': 'Skill translation suggestion', + }, + csrf_token=csrf_token, + ) + self.logout() + + author_stats = suggestion_models.TranslationSubmitterTotalContributionStatsModel.get( + 'hi', self.author_id + ) + assert author_stats is not None + self.assertEqual(author_stats.submitted_translations_count, 1) + + all_suggestions = suggestion_services.query_suggestions( + [('author_id', self.author_id), ('target_id', self.skill_id)] + ) + suggestions = [ + s + for s in all_suggestions + if s.suggestion_type == feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT + ] + self.assertEqual(len(suggestions), 1) + suggestion = suggestions[0] + + self.login(self.CURRICULUM_ADMIN_EMAIL) + csrf_token = self.get_new_csrf_token() + self.put_json( + '%s/skill/%s/%s' + % ( + feconf.SUGGESTION_ACTION_URL_PREFIX, + self.skill_id, + suggestion.suggestion_id, + ), + {'action': 'accept', 'review_message': 'Accepted!'}, + csrf_token=csrf_token, + ) + self.logout() + + author_stats = suggestion_models.TranslationSubmitterTotalContributionStatsModel.get( + 'hi', self.author_id + ) + assert author_stats is not None + self.assertEqual(author_stats.accepted_translations_count, 1) + + reviewer_stats = suggestion_models.TranslationReviewerTotalContributionStatsModel.get( + 'hi', self.admin_id + ) + assert reviewer_stats is not None + self.assertEqual(reviewer_stats.reviewed_translations_count, 1) + + def test_reject_skill_translation_suggestion(self) -> None: + self.login(self.AUTHOR_EMAIL) + csrf_token = self.get_new_csrf_token() + + self.post_json( + '%s/' % feconf.SUGGESTION_URL_PREFIX, + { + 'suggestion_type': feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + 'target_type': feconf.ENTITY_TYPE_SKILL, + 'target_id': self.skill_id, + 'target_version_at_submission': 1, + 'change_cmd': { + 'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION, + 'state_name': constants.DEFAULT_SUGGESTION_STATE_NAME, + 'content_id': feconf.SKILL_DESCRIPTION_CONTENT_ID, + 'language_code': 'hi', + 'content_html': 'Description', + 'translation_html': '

Skill description in Hindi

', + 'data_format': 'html', + }, + 'description': 'Skill translation suggestion', + }, + csrf_token=csrf_token, + ) + self.logout() + + all_suggestions = suggestion_services.query_suggestions( + [('author_id', self.author_id), ('target_id', self.skill_id)] + ) + suggestions = [ + s + for s in all_suggestions + if s.suggestion_type == feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT + ] + self.assertEqual(len(suggestions), 1) + suggestion = suggestions[0] + + self.login(self.CURRICULUM_ADMIN_EMAIL) + csrf_token = self.get_new_csrf_token() + self.put_json( + '%s/skill/%s/%s' + % ( + feconf.SUGGESTION_ACTION_URL_PREFIX, + self.skill_id, + suggestion.suggestion_id, + ), + {'action': 'reject', 'review_message': 'Rejected!'}, + csrf_token=csrf_token, + ) + self.logout() + + reviewer_stats = suggestion_models.TranslationReviewerTotalContributionStatsModel.get( + 'hi', self.admin_id + ) + assert reviewer_stats is not None + self.assertEqual(reviewer_stats.reviewed_translations_count, 1) + class UserSubmittedSuggestionsHandlerTest(test_utils.GenericTestBase): """Unit test for the UserSubmittedSuggestionsHandler.""" diff --git a/core/domain/certificate_assessment_domain.py b/core/domain/certificate_assessment_domain.py index c13e5ed797e48..a27ef5ee15ab7 100644 --- a/core/domain/certificate_assessment_domain.py +++ b/core/domain/certificate_assessment_domain.py @@ -362,6 +362,18 @@ def __init__( self.finished_at = finished_at self.is_submitted = is_submitted + def get_time_taken_in_minutes(self) -> Optional[int]: + """Returns how long the attempt took, in whole minutes. + + Returns: + int|None. The elapsed time between the attempt start and + finish, in minutes, or None if the attempt has not been + finished yet. + """ + if self.finished_at is None: + return None + return int((self.finished_at - self.started_at).total_seconds() / 60) + def _validate_ids_and_scores(self) -> None: """Validates the attempt identity and score fields.""" if not isinstance(self.attempt_id, str) or not self.attempt_id: diff --git a/core/domain/certificate_assessment_domain_test.py b/core/domain/certificate_assessment_domain_test.py index 155e89c1807f4..7997cb5b73e7e 100644 --- a/core/domain/certificate_assessment_domain_test.py +++ b/core/domain/certificate_assessment_domain_test.py @@ -371,6 +371,19 @@ def test_init_sets_all_attributes_correctly(self) -> None: def test_validate_succeeds_for_valid_attempt(self) -> None: self._get_sample_attempt().validate() + def test_get_time_taken_in_minutes_returns_elapsed_time(self) -> None: + attempt = self._get_sample_attempt() + + self.assertEqual(attempt.get_time_taken_in_minutes(), 20) + + def test_get_time_taken_in_minutes_returns_none_for_unfinished_attempt( + self, + ) -> None: + attempt = self._get_sample_attempt() + attempt.finished_at = None + + self.assertIsNone(attempt.get_time_taken_in_minutes()) + def test_validate_succeeds_when_finished_at_is_none(self) -> None: attempt = self._get_sample_attempt() attempt.finished_at = None diff --git a/core/domain/certificate_assessment_services.py b/core/domain/certificate_assessment_services.py index 964d299f5874b..884bcdc3ed55a 100644 --- a/core/domain/certificate_assessment_services.py +++ b/core/domain/certificate_assessment_services.py @@ -1186,6 +1186,37 @@ def get_certificate_assessment_offering( return _model_to_domain(certificate_assessment_offering_model) +def get_certificate_assessment_offerings_by_ids( + certificate_ids: List[str], +) -> Dict[str, certificate_assessment_domain.CertificateAssessmentOffering]: + """Returns a mapping from certificate ID to certificate assessment offering. + + Args: + certificate_ids: list(str). The IDs of the certificate assessment + offerings to fetch. + + Returns: + dict(str, CertificateAssessmentOffering). A mapping from each requested + certificate ID to its certificate assessment offering. Only IDs for + which an offering exists are included in the mapping. + """ + certificate_assessment_offering_models = ( + gae_models.CertificateAssessmentOfferingModel.get_multi(certificate_ids) + ) + offerings_by_id: Dict[ + str, certificate_assessment_domain.CertificateAssessmentOffering + ] = {} + for certificate_id, certificate_assessment_offering_model in zip( + certificate_ids, certificate_assessment_offering_models + ): + if certificate_assessment_offering_model is None: + continue + offerings_by_id[certificate_id] = _model_to_domain( + certificate_assessment_offering_model + ) + return offerings_by_id + + def update_certificate_assessment_offering( certificate_id: str, title: str, @@ -1323,6 +1354,97 @@ def get_certificate_assessment_offerings() -> ( ] +class CertificateAssessmentAttemptNotFoundException(Exception): + """Exception raised when a certificate assessment attempt is missing.""" + + pass + + +def _attempt_model_to_domain( + attempt_model: gae_models.CertificateAssessmentAttemptModel, +) -> certificate_assessment_domain.CertificateAssessmentAttempt: + """Converts a certificate assessment attempt storage model to a domain + object. + + Args: + attempt_model: CertificateAssessmentAttemptModel. The storage model + to convert. + + Returns: + CertificateAssessmentAttempt. The corresponding domain object. + """ + return certificate_assessment_domain.CertificateAssessmentAttempt( + attempt_id=attempt_model.id, + learner_id=attempt_model.learner_id, + total_score=attempt_model.total_score, + attempt_index=attempt_model.attempt_index, + attempt_data=attempt_model.attempt_data, + version_data=attempt_model.version_data, + started_at=attempt_model.started_at, + finished_at=attempt_model.finished_at, + is_submitted=attempt_model.is_submitted, + ) + + +def get_certificate_attempt( + attempt_id: str, +) -> certificate_assessment_domain.CertificateAssessmentAttempt: + """Returns a single certificate assessment attempt with full result data. + + Args: + attempt_id: str. The ID of the certificate assessment attempt. + + Returns: + CertificateAssessmentAttempt. The attempt with the given ID, + including its score and per-topic result data. + + Raises: + CertificateAssessmentAttemptNotFoundException. The attempt does not + exist. + """ + attempt_model = gae_models.CertificateAssessmentAttemptModel.get_by_id( + attempt_id + ) + if attempt_model is None: + raise CertificateAssessmentAttemptNotFoundException( + 'Certificate assessment attempt %s does not exist.' % attempt_id + ) + + return _attempt_model_to_domain(attempt_model) + + +def get_certificate_attempts( + learner_id: str, +) -> List[certificate_assessment_domain.CertificateAssessmentAttempt]: + """Returns all certificate assessment attempts for a learner. + + Args: + learner_id: str. The ID of the learner. + + Returns: + list(CertificateAssessmentAttempt). All attempts made by the learner, + ordered by attempt_index. + """ + attempt_models: List[ + gae_models.CertificateAssessmentAttemptModel + # Here we use cast because the datastore fetch returns a generic sequence and + # mypy cannot infer the concrete CertificateAssessmentAttemptModel item + # type from this storage-layer API. + ] = cast( + List[gae_models.CertificateAssessmentAttemptModel], + gae_models.CertificateAssessmentAttemptModel.query( + gae_models.CertificateAssessmentAttemptModel.learner_id + == learner_id + ) + .order(gae_models.CertificateAssessmentAttemptModel.attempt_index) + .fetch(), + ) + return [ + _attempt_model_to_domain(attempt_model) + for attempt_model in attempt_models + ] + + def get_certificate_offerings_for_classroom( classroom_url_fragment: str, learner_id: str ) -> List[CertificateOfferingClassroomSummary]: diff --git a/core/domain/certificate_assessment_services_test.py b/core/domain/certificate_assessment_services_test.py index d0df251e43796..9457a178800de 100644 --- a/core/domain/certificate_assessment_services_test.py +++ b/core/domain/certificate_assessment_services_test.py @@ -43,9 +43,20 @@ MYPY = False if MYPY: # pragma: no cover - from mypy_imports import skill_models - -(skill_models,) = models.Registry.import_models([models.Names.SKILL]) + from mypy_imports import ( + certificate_assessment_offering_models, + skill_models, + ) + +( + certificate_assessment_offering_models, + skill_models, +) = models.Registry.import_models( + [ + models.Names.CERTIFICATE_ASSESSMENT_OFFERING, + models.Names.SKILL, + ] +) CERTIFICATE_DIFFICULTY_EASY = ( certificate_assessment_services.CERTIFICATE_ASSESSMENT_DIFFICULTY_EASY @@ -1240,6 +1251,77 @@ def _get_returns_submitted( [], ) + def test_get_certificate_assessment_offerings_by_ids_returns_mapping( + self, + ) -> None: + first_offering = certificate_assessment_services.create_certificate_assessment_offering( + title='Geography Essentials', + description='Covers maps and spatial reasoning.', + classroom_id=self.classroom_id, + topic_ids=[self.topic_id], + total_questions=6, + time_limit_in_minutes=30, + demonstrates=['Map reading'], + async_status='Available', + ) + second_offering = certificate_assessment_services.create_certificate_assessment_offering( + title='Biology Basics', + description='Covers cells and ecosystems.', + classroom_id=self.classroom_id, + topic_ids=[self.topic_id], + total_questions=6, + time_limit_in_minutes=30, + demonstrates=['Living systems'], + async_status='Available', + ) + + offerings_by_id = certificate_assessment_services.get_certificate_assessment_offerings_by_ids( + [ + first_offering.certificate_id, + second_offering.certificate_id, + ] + ) + + self.assertEqual( + set(offerings_by_id.keys()), + {first_offering.certificate_id, second_offering.certificate_id}, + ) + self.assertEqual( + offerings_by_id[first_offering.certificate_id].title, + 'Geography Essentials', + ) + self.assertEqual( + offerings_by_id[second_offering.certificate_id].title, + 'Biology Basics', + ) + + def test_get_certificate_assessment_offerings_by_ids_omits_missing( + self, + ) -> None: + created_offering = certificate_assessment_services.create_certificate_assessment_offering( + title='Biology Basics', + description='Covers cells and ecosystems.', + classroom_id=self.classroom_id, + topic_ids=[self.topic_id], + total_questions=6, + time_limit_in_minutes=30, + demonstrates=['Living systems'], + async_status='Available', + ) + + offerings_by_id = certificate_assessment_services.get_certificate_assessment_offerings_by_ids( + [created_offering.certificate_id, 'non_existent_certificate'] + ) + + self.assertEqual( + offerings_by_id, + { + created_offering.certificate_id: offerings_by_id[ + created_offering.certificate_id + ] + }, + ) + def _create_attempt( self, learner_id: str, @@ -2570,48 +2652,129 @@ def test_validation_counts_easy_linked_question_as_available(self) -> None: topic_errors[CERTIFICATE_DIFFICULTY_EASY]['required'], 1 ) - def test_validation_returns_valid_when_questions_satisfy_all_buckets( + +class CertificateAssessmentAttemptServicesTest(test_utils.GenericTestBase): + """Tests for certificate assessment attempt services.""" + + AUTO_CREATE_DEFAULT_SUPERADMIN_USER = False + + def setUp(self) -> None: + super().setUp() + self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME) + self.learner_id = self.get_user_id_from_email(self.OWNER_EMAIL) + + def _create_attempt( self, - ) -> None: - topic = mock.Mock() - topic.name = 'Mock Topic' - topic.get_all_skill_ids.return_value = [ - 'skill_easy', - 'skill_medium', - 'skill_hard', - ] - skill = mock.Mock() - skill.description = 'Skill description' + learner_id: str, + total_score: float, + attempt_index: int, + ) -> ( + certificate_assessment_offering_models.CertificateAssessmentAttemptModel + ): + """Creates and returns a certificate assessment attempt model. - question_links = [ - mock.Mock(question_id='easy_1', skill_difficulty=0.3), - mock.Mock(question_id='medium_1', skill_difficulty=0.6), - mock.Mock(question_id='hard_1', skill_difficulty=0.9), - ] + Args: + learner_id: str. The ID of the learner making the attempt. + total_score: float. The total score achieved in the attempt. + attempt_index: int. The index of the attempt for the learner. - with mock.patch.object( - topic_fetchers, - 'get_topics_by_ids', - return_value=[topic], - ), mock.patch.object( - skill_models.SkillModel, - 'get_multi', - return_value=[mock.Mock(), mock.Mock(), mock.Mock()], - ), mock.patch.object( - skill_fetchers, - 'get_skill_from_model', - return_value=skill, - ), mock.patch.object( - question_services, - 'get_question_skill_links_of_skill', - return_value=question_links, + Returns: + CertificateAssessmentAttemptModel. The created attempt model. + """ + return certificate_assessment_offering_models.CertificateAssessmentAttemptModel.create( + learner_id=learner_id, + total_score=total_score, + attempt_index=attempt_index, + attempt_data={ + 'topic_id_101': { + 'total_related_questions': 5, + 'total_correct_questions': 3, + } + }, + version_data={ + 'certificate_id': 'cert_abc123', + 'certificate_version': 1, + 'topic_versions': {'topic_id_101': 2}, + 'question_versions': {'question_id_1': 1}, + 'question_topic_links': {'question_id_1': ['topic_id_101']}, + }, + started_at=datetime.datetime(2026, 7, 18), + finished_at=None, + is_submitted=True, + ) + + def test_get_certificate_attempt_returns_full_result_data(self) -> None: + created_attempt = self._create_attempt(self.learner_id, 84.5, 1) + + attempt = certificate_assessment_services.get_certificate_attempt( + created_attempt.id + ) + + self.assertEqual(attempt.attempt_id, created_attempt.id) + self.assertEqual(attempt.learner_id, self.learner_id) + self.assertEqual(attempt.total_score, 84.5) + self.assertEqual(attempt.attempt_index, 1) + self.assertEqual( + attempt.attempt_data, + { + 'topic_id_101': { + 'total_related_questions': 5, + 'total_correct_questions': 3, + } + }, + ) + self.assertEqual(attempt.version_data['certificate_id'], 'cert_abc123') + self.assertEqual(attempt.started_at, datetime.datetime(2026, 7, 18)) + self.assertTrue(attempt.is_submitted) + + def test_get_certificate_attempt_raises_for_missing_attempt(self) -> None: + with self.assertRaisesRegex( + certificate_assessment_services.CertificateAssessmentAttemptNotFoundException, + 'Certificate assessment attempt missing_attempt_id does not exist.', ): - result = certificate_assessment_services.validate_certificate_assessment_offering( - topic_ids=[self.topic_id], - total_questions=3, + certificate_assessment_services.get_certificate_attempt( + 'missing_attempt_id' ) - self.assertTrue(result['is_valid']) + def test_get_certificate_attempts_returns_all_attempts_in_index_order( + self, + ) -> None: + first_attempt = self._create_attempt(self.learner_id, 60.0, 1) + second_attempt = self._create_attempt(self.learner_id, 84.5, 2) + + attempts = certificate_assessment_services.get_certificate_attempts( + self.learner_id + ) + + self.assertEqual(len(attempts), 2) self.assertEqual( - result['validation_message'], 'Certificate assessment is valid.' + [attempt.attempt_id for attempt in attempts], + [first_attempt.id, second_attempt.id], ) + self.assertEqual( + [attempt.attempt_index for attempt in attempts], [1, 2] + ) + + def test_get_certificate_attempts_returns_empty_for_learner_without_attempts( + self, + ) -> None: + attempts = certificate_assessment_services.get_certificate_attempts( + self.learner_id + ) + + self.assertEqual(attempts, []) + + def test_get_certificate_attempts_only_returns_matching_learner( + self, + ) -> None: + self._create_attempt(self.learner_id, 60.0, 1) + self.signup('otheruser@example.com', 'otheruser') + other_learner_id = self.get_user_id_from_email('otheruser@example.com') + other_attempt = self._create_attempt(other_learner_id, 90.0, 1) + + attempts = certificate_assessment_services.get_certificate_attempts( + other_learner_id + ) + + self.assertEqual(len(attempts), 1) + self.assertEqual(attempts[0].attempt_id, other_attempt.id) diff --git a/core/domain/suggestion_registry.py b/core/domain/suggestion_registry.py index 042b1c2adbde3..34f5d6f604233 100644 --- a/core/domain/suggestion_registry.py +++ b/core/domain/suggestion_registry.py @@ -30,6 +30,7 @@ exp_services, fs_services, html_cleaner, + opportunity_services, platform_parameter_list, platform_parameter_services, question_domain, @@ -458,6 +459,7 @@ def __init__( edited_by_reviewer: bool, last_updated: datetime.datetime, created_on: datetime.datetime, + target_type: str = feconf.ENTITY_TYPE_EXPLORATION, ) -> None: """Initializes an object of type SuggestionEditStateContent corresponding to the SUGGESTION_TYPE_EDIT_STATE_CONTENT choice. @@ -465,7 +467,7 @@ def __init__( super().__init__(status, final_reviewer_id) self.suggestion_id = suggestion_id self.suggestion_type = feconf.SUGGESTION_TYPE_EDIT_STATE_CONTENT - self.target_type = feconf.ENTITY_TYPE_EXPLORATION + self.target_type = target_type self.target_id = target_id self.target_version_at_submission = target_version_at_submission self.author_id = author_id @@ -691,6 +693,7 @@ def __init__( edited_by_reviewer: bool, last_updated: datetime.datetime, created_on: datetime.datetime, + target_type: str = feconf.ENTITY_TYPE_EXPLORATION, ) -> None: """Initializes an object of type SuggestionTranslateContent corresponding to the SUGGESTION_TYPE_TRANSLATE_CONTENT choice. @@ -698,7 +701,7 @@ def __init__( super().__init__(status, final_reviewer_id) self.suggestion_id = suggestion_id self.suggestion_type = feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT - self.target_type = feconf.ENTITY_TYPE_EXPLORATION + self.target_type = target_type self.target_id = target_id self.target_version_at_submission = target_version_at_submission self.author_id = author_id @@ -827,34 +830,52 @@ def pre_accept_validate(self) -> None: before accepting the suggestion. """ self.validate() - exploration = exp_fetchers.get_exploration_by_id(self.target_id) - if ( + entity = opportunity_services.get_entity_by_type_and_id( + self.target_type, self.target_id + ) + if not isinstance(entity, translation_domain.BaseTranslatableObject): + raise utils.ValidationError( + 'Expected entity to be a translatable object' + ) + + translates_metadata = ( self.change_cmd.state_name == constants.DEFAULT_SUGGESTION_STATE_NAME - ): - translatable_contents = ( - exploration.get_translatable_contents_collection( - override_metadata_feature_flag=True - ) - ) - if ( - self.change_cmd.content_id - not in translatable_contents.content_id_to_translatable_content + ) + # Only explorations organise their translatable content into states, so + # every other entity type uses the default state name as a sentinel. + if not translates_metadata: + if not isinstance(entity, exp_domain.Exploration) or ( + self.change_cmd.state_name not in entity.states ): - raise utils.ValidationError( - 'Expected %s to be a valid metadata content ID' - % self.change_cmd.content_id - ) - else: - if self.change_cmd.state_name not in exploration.states: raise utils.ValidationError( 'Expected %s to be a valid state name' % self.change_cmd.state_name ) + # A valid state name does not guarantee a valid content ID, so the + # content ID is checked against the entity's translatable contents for + # every entity type. + translatable_contents = entity.get_translatable_contents_collection( + override_metadata_feature_flag=True + ) + if ( + self.change_cmd.content_id + not in translatable_contents.content_id_to_translatable_content + ): + msg = ( + 'Expected %s to be a valid metadata content ID' + if translates_metadata + and self.target_type == feconf.ENTITY_TYPE_EXPLORATION + else 'Expected %s to be a valid content ID' + ) + raise utils.ValidationError(msg % self.change_cmd.content_id) + def accept(self, unused_commit_message: str) -> None: """Accepts the suggestion.""" - exploration = exp_fetchers.get_exploration_by_id(self.target_id) + entity = opportunity_services.get_entity_by_type_and_id( + self.target_type, self.target_id + ) translated_content = translation_domain.TranslatedContent( self.change_cmd.translation_html, @@ -865,9 +886,9 @@ def accept(self, unused_commit_message: str) -> None: ) translation_services.add_new_translation( - feconf.TranslatableEntityType.EXPLORATION, + feconf.TranslatableEntityType(self.target_type), self.target_id, - exploration.version, + entity.version, self.language_code, self.change_cmd.content_id, translated_content, @@ -976,6 +997,7 @@ def __init__( edited_by_reviewer: bool, last_updated: datetime.datetime, created_on: datetime.datetime, + target_type: str = feconf.ENTITY_TYPE_SKILL, ) -> None: """Initializes an object of type SuggestionAddQuestion corresponding to the SUGGESTION_TYPE_ADD_QUESTION choice. @@ -983,7 +1005,7 @@ def __init__( super().__init__(status, final_reviewer_id) self.suggestion_id = suggestion_id self.suggestion_type = feconf.SUGGESTION_TYPE_ADD_QUESTION - self.target_type = feconf.ENTITY_TYPE_SKILL + self.target_type = target_type self.target_id = target_id self.target_version_at_submission = target_version_at_submission self.author_id = author_id diff --git a/core/domain/suggestion_registry_test.py b/core/domain/suggestion_registry_test.py index ffed04a501fef..969d217d64897 100644 --- a/core/domain/suggestion_registry_test.py +++ b/core/domain/suggestion_registry_test.py @@ -29,6 +29,7 @@ feature_flag_services, fs_services, html_validation_service, + opportunity_services, platform_parameter_list, question_domain, question_services, @@ -1952,6 +1953,34 @@ def test_pre_accept_validate_state_name(self) -> None: ): suggestion.pre_accept_validate() + def test_pre_accept_validate_state_content_id(self) -> None: + self.save_new_default_exploration('exp1', self.author_id) + expected_suggestion_dict = self.suggestion_dict + suggestion = suggestion_registry.SuggestionTranslateContent( + expected_suggestion_dict['suggestion_id'], + expected_suggestion_dict['target_id'], + expected_suggestion_dict['target_version_at_submission'], + expected_suggestion_dict['status'], + self.author_id, + self.reviewer_id, + expected_suggestion_dict['change_cmd'], + expected_suggestion_dict['score_category'], + expected_suggestion_dict['language_code'], + False, + self.fake_date, + self.fake_date, + ) + suggestion.change_cmd.state_name = 'Introduction' + + # A valid state name must not be enough on its own: the content ID has + # to belong to the exploration as well. + suggestion.change_cmd.content_id = 'invalid_content_id' + with self.assertRaisesRegex( + utils.ValidationError, + 'Expected invalid_content_id to be a valid content ID', + ): + suggestion.pre_accept_validate() + def test_pre_accept_validate_metadata_content_id(self) -> None: self.save_new_default_exploration('exp1', self.author_id) expected_suggestion_dict = self.suggestion_dict.copy() @@ -1995,6 +2024,62 @@ def test_pre_accept_validate_metadata_content_id(self) -> None: ): suggestion.pre_accept_validate() + def test_pre_accept_validate_skill_translation_suggestion(self) -> None: + self.save_new_skill('skill1', self.author_id, description='Skill 1') + expected_suggestion_dict = self.suggestion_dict.copy() + suggestion = suggestion_registry.SuggestionTranslateContent( + expected_suggestion_dict['suggestion_id'], + 'skill1', + expected_suggestion_dict['target_version_at_submission'], + expected_suggestion_dict['status'], + self.author_id, + self.reviewer_id, + { + 'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION, + 'state_name': constants.DEFAULT_SUGGESTION_STATE_NAME, + 'content_id': feconf.SKILL_DESCRIPTION_CONTENT_ID, + 'language_code': 'hi', + 'content_html': 'original description', + 'translation_html': 'translated description', + 'data_format': 'unicode', + }, + expected_suggestion_dict['score_category'], + expected_suggestion_dict['language_code'], + False, + self.fake_date, + self.fake_date, + target_type=feconf.ENTITY_TYPE_SKILL, + ) + + suggestion.pre_accept_validate() + + suggestion.target_id = 'non_existent_skill_id' + with self.assertRaisesRegex( + Exception, 'No skill exists with ID: non_existent_skill_id' + ): + suggestion.pre_accept_validate() + + suggestion.target_id = 'skill1' + suggestion.change_cmd.content_id = 'invalid_content_id' + with self.assertRaisesRegex( + utils.ValidationError, + 'Expected invalid_content_id to be a valid content ID', + ): + suggestion.pre_accept_validate() + suggestion.change_cmd.content_id = feconf.SKILL_DESCRIPTION_CONTENT_ID + + suggestion.target_id = 'skill1' + with self.swap( + opportunity_services, + 'get_entity_by_type_and_id', + lambda *args, **kwargs: object(), + ): + with self.assertRaisesRegex( + utils.ValidationError, + 'Expected entity to be a translatable object', + ): + suggestion.pre_accept_validate() + def test_accept_suggestion_adds_translation_in_exploration(self) -> None: exp = self.save_new_default_exploration('exp1', self.author_id) translations = ( diff --git a/core/domain/suggestion_services.py b/core/domain/suggestion_services.py index eb36c1df91703..9e2d65bd6bfbb 100644 --- a/core/domain/suggestion_services.py +++ b/core/domain/suggestion_services.py @@ -28,6 +28,7 @@ from core.domain import ( contribution_stats_services, email_manager, + exp_domain, exp_fetchers, feature_flag_services, feedback_services, @@ -216,10 +217,11 @@ def create_suggestion( ) status = suggestion_models.STATUS_IN_REVIEW - + exploration: Optional[exp_domain.Exploration] = None if target_type == feconf.ENTITY_TYPE_EXPLORATION: exploration = exp_fetchers.get_exploration_by_id(target_id) if suggestion_type == feconf.SUGGESTION_TYPE_EDIT_STATE_CONTENT: + assert exploration is not None score_category = '%s%s%s' % ( suggestion_models.SCORE_TYPE_CONTENT, suggestion_models.SCORE_CATEGORY_DELIMITER, @@ -316,7 +318,6 @@ def create_suggestion( 'The Skill content has changed since this translation ' 'was submitted.' ) - # Do not allow creating a suggestion if there is already a suggestion # in review for the same content_id and language_code. existing_suggestions = suggestion_models.GeneralSuggestionModel.get_translation_suggestions_in_review_with_exp_id( @@ -370,6 +371,7 @@ def create_suggestion( False, utils.get_current_utc_datetime(), utils.get_current_utc_datetime(), + target_type=target_type, ) elif suggestion_type == feconf.SUGGESTION_TYPE_ADD_QUESTION: score_category = '%s%s%s' % ( @@ -460,6 +462,10 @@ def get_suggestion_from_model( suggestion_model.edited_by_reviewer, suggestion_model.last_updated, suggestion_model.created_on, + # A translation suggestion can target any translatable entity type, so + # the target type comes from the model rather than the default assumed + # by the domain class. + target_type=suggestion_model.target_type, ) @@ -927,15 +933,17 @@ def accept_suggestion( ) # Do not allow accepting a suggestion if the content has already been - # translated and is up-to-date. We use the current exploration version + # translated and is up-to-date. We use the current entity version # (not the version at submission) to match the version used when saving # the translation in suggestion_registry.py. if suggestion.suggestion_type == feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT: - exploration = exp_fetchers.get_exploration_by_id(suggestion.target_id) + target_entity = opportunity_services.get_entity_by_type_and_id( + suggestion.target_type, suggestion.target_id + ) entity_translation = translation_fetchers.get_entity_translation( feconf.TranslatableEntityType(suggestion.target_type), suggestion.target_id, - exploration.version, + target_entity.version, suggestion.language_code, ) if suggestion.change_cmd.content_id in entity_translation.translations: @@ -3296,6 +3304,54 @@ def _update_question_reviewer_total_stats_models( ) +def _get_topic_id_of_translation_target( + suggestion: suggestion_registry.BaseSuggestion, +) -> str: + """Returns the ID of the topic that the target of the given translation + suggestion belongs to. + + Args: + suggestion: Suggestion. The translation suggestion whose target's topic + is required. + + Returns: + str. The ID of the topic that the target belongs to, or + 'uncategorized' if the target is not part of any topic. + """ + # The exploration opportunity summaries are removed once the new + # opportunity models are rolled out, so they are only consulted while the + # feature flag is off. With the flag on, every entity type resolves its + # topic from the new opportunity model. + if ( + suggestion.target_type == feconf.ENTITY_TYPE_EXPLORATION + and not feature_flag_services.is_feature_flag_enabled( + feature_flag_list.FeatureNames.ENABLE_TRANSLATION_OPPORTUNITIES_WITH_NEW_OPP_MODELS.value, + None, + ) + ): + exp_opportunity = ( + opportunity_services.get_exploration_opportunity_summary_by_id( + suggestion.target_id + ) + ) + # We can confirm that exp_opportunity will not be None since there + # should be an assigned opportunity for a given translation. Hence we + # can rule out the possibility of None for mypy type checking. + assert exp_opportunity is not None + return exp_opportunity.topic_id + + opportunity = ( + opportunity_services.get_translation_opportunities_by_entity_ids( + suggestion.target_type, [suggestion.target_id] + )[suggestion.target_id] + ) + # An entity such as a skill can be translated before it is assigned to any + # topic, in which case the contribution is not attributable to a topic. + if opportunity is None or not opportunity.topic_ids: + return 'uncategorized' + return opportunity.topic_ids[0] + + def update_translation_contribution_stats_at_submission( suggestion: suggestion_registry.BaseSuggestion, ) -> None: @@ -3308,16 +3364,7 @@ def update_translation_contribution_stats_at_submission( submitted. """ content_word_count = 0 - exp_opportunity = ( - opportunity_services.get_exploration_opportunity_summary_by_id( - suggestion.target_id - ) - ) - # We can confirm that exp_opportunity will not be None since there should - # be an assigned opportunity for a given translation. Hence we can rule out - # the possibility of None for mypy type checking. - assert exp_opportunity is not None - topic_id = exp_opportunity.topic_id + topic_id = _get_topic_id_of_translation_target(suggestion) if isinstance(suggestion.change_cmd.translation_html, list): for content in suggestion.change_cmd.translation_html: @@ -3484,16 +3531,7 @@ def update_translation_contribution_stats_at_review( reviewed. """ content_word_count = 0 - exp_opportunity = ( - opportunity_services.get_exploration_opportunity_summary_by_id( - suggestion.target_id - ) - ) - # We can confirm that exp_opportunity will not be None since there should - # be an assigned opportunity for a given translation. Hence we can rule out - # the possibility of None for mypy type checking. - assert exp_opportunity is not None - topic_id = exp_opportunity.topic_id + topic_id = _get_topic_id_of_translation_target(suggestion) if isinstance(suggestion.change_cmd.translation_html, list): for content in suggestion.change_cmd.translation_html: @@ -3644,16 +3682,7 @@ def update_translation_review_stats( raise Exception( 'The final_reviewer_id in the suggestion should not be None.' ) - exp_opportunity = ( - opportunity_services.get_exploration_opportunity_summary_by_id( - suggestion.target_id - ) - ) - # We can confirm that exp_opportunity will not be None since there should - # be an assigned opportunity for a given translation. Hence we can rule out - # the possibility of None for mypy type checking. - assert exp_opportunity is not None - topic_id = exp_opportunity.topic_id + topic_id = _get_topic_id_of_translation_target(suggestion) suggestion_is_accepted = ( suggestion.status == suggestion_models.STATUS_ACCEPTED ) diff --git a/core/domain/suggestion_services_test.py b/core/domain/suggestion_services_test.py index 4f24c4c5726c9..52ffc247ea7dd 100644 --- a/core/domain/suggestion_services_test.py +++ b/core/domain/suggestion_services_test.py @@ -479,7 +479,7 @@ def test_create_translation_suggestion_fails_if_duplicate_exists( feconf.ENTITY_TYPE_EXPLORATION, self.target_id, exp.version, - 'author_2', + self.normal_user_id, change_dict, 'test description', ) @@ -781,6 +781,177 @@ def test_accept_suggestion_succeeds_if_translation_needs_update( suggestion_models.STATUS_ACCEPTED, ) + def test_create_and_accept_skill_translation_suggestion(self) -> None: + """Test creating and accepting a translation suggestion targeting a skill.""" + skill_id = 'skill_1' + self.save_new_skill( + skill_id, self.author_id, description='Skill Description' + ) + + change_dict = { + 'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION, + 'state_name': constants.DEFAULT_SUGGESTION_STATE_NAME, + 'content_id': feconf.SKILL_DESCRIPTION_CONTENT_ID, + 'language_code': 'hi', + 'content_html': 'Skill Description', + 'translation_html': 'Skill Description in Hindi', + 'data_format': 'unicode', + } + + suggestion = suggestion_services.create_suggestion( + feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + feconf.ENTITY_TYPE_SKILL, + skill_id, + 1, + self.author_id, + change_dict, + 'Skill translation suggestion description', + ) + + self.assertEqual(suggestion.target_type, feconf.ENTITY_TYPE_SKILL) + self.assertEqual(suggestion.status, suggestion_models.STATUS_IN_REVIEW) + self.assertEqual( + suggestion.score_category, + '%s.%s' + % ( + suggestion_models.SCORE_TYPE_TRANSLATION, + feconf.ENTITY_TYPE_SKILL, + ), + ) + + with self.swap( + opportunity_services, + 'update_translation_opportunity_with_accepted_suggestion', + lambda *args: None, + ): + suggestion_services.accept_suggestion( + suggestion.suggestion_id, + self.reviewer_id, + 'UNUSED_COMMIT_MESSAGE', + 'Accepted skill translation', + ) + + updated_suggestion = suggestion_services.get_suggestion_by_id( + suggestion.suggestion_id + ) + self.assertEqual( + updated_suggestion.status, suggestion_models.STATUS_ACCEPTED + ) + + @test_utils.enable_feature_flags( + [ + feature_flag_list.FeatureNames.ENABLE_TRANSLATION_OPPORTUNITIES_WITH_NEW_OPP_MODELS + ] + ) + def test_accepting_skill_translation_updates_translation_counts( + self, + ) -> None: + """Test that accepting a translation targeting a skill updates the + translation counts on the skill's translation opportunity. + """ + skill_id = 'skill_3' + self.save_new_skill( + skill_id, self.author_id, description='Skill Description' + ) + opportunity_model = opportunity_models.TranslationOpportunityModel( + id='%s.%s' % (feconf.ENTITY_TYPE_SKILL, skill_id), + entity_type=feconf.ENTITY_TYPE_SKILL, + entity_id=skill_id, + topic_ids=['topic_1'], + content_count=1, + incomplete_translation_language_codes=['hi'], + translation_counts={}, + ) + opportunity_model.update_timestamps() + opportunity_model.put() + + change_dict = { + 'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION, + 'state_name': constants.DEFAULT_SUGGESTION_STATE_NAME, + 'content_id': feconf.SKILL_DESCRIPTION_CONTENT_ID, + 'language_code': 'hi', + 'content_html': 'Skill Description', + 'translation_html': 'Skill Description in Hindi', + 'data_format': 'unicode', + } + suggestion = suggestion_services.create_suggestion( + feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + feconf.ENTITY_TYPE_SKILL, + skill_id, + 1, + self.author_id, + change_dict, + 'Skill translation suggestion description', + ) + + suggestion_services.accept_suggestion( + suggestion.suggestion_id, + self.reviewer_id, + 'UNUSED_COMMIT_MESSAGE', + 'Accepted skill translation', + ) + + updated_opportunity = ( + opportunity_models.TranslationOpportunityModel.get( + '%s.%s' % (feconf.ENTITY_TYPE_SKILL, skill_id) + ) + ) + self.assertEqual(updated_opportunity.translation_counts, {'hi': 1}) + self.assertNotIn( + 'hi', updated_opportunity.incomplete_translation_language_codes + ) + + def test_skill_translation_stats_use_topic_of_the_opportunity( + self, + ) -> None: + """Test that the stats of a translation targeting a skill are + attributed to the topic that the skill's opportunity belongs to. + """ + skill_id = 'skill_2' + self.save_new_skill( + skill_id, self.author_id, description='Skill Description' + ) + opportunity_model = opportunity_models.TranslationOpportunityModel( + id='%s.%s' % (feconf.ENTITY_TYPE_SKILL, skill_id), + entity_type=feconf.ENTITY_TYPE_SKILL, + entity_id=skill_id, + topic_ids=['topic_1'], + content_count=1, + incomplete_translation_language_codes=['hi'], + translation_counts={}, + ) + opportunity_model.update_timestamps() + opportunity_model.put() + + change_dict = { + 'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION, + 'state_name': constants.DEFAULT_SUGGESTION_STATE_NAME, + 'content_id': feconf.SKILL_DESCRIPTION_CONTENT_ID, + 'language_code': 'hi', + 'content_html': 'Skill Description', + 'translation_html': 'Skill Description in Hindi', + 'data_format': 'unicode', + } + suggestion = suggestion_services.create_suggestion( + feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + feconf.ENTITY_TYPE_SKILL, + skill_id, + 1, + self.author_id, + change_dict, + 'Skill translation suggestion description', + ) + + suggestion_services.update_translation_contribution_stats_at_submission( + suggestion + ) + + stats_model = suggestion_models.TranslationContributionStatsModel.get( + 'hi', self.author_id, 'topic_1' + ) + assert stats_model is not None + self.assertEqual(stats_model.submitted_translations_count, 1) + def test_get_submitted_submissions(self) -> None: suggestion_services.create_suggestion( feconf.SUGGESTION_TYPE_EDIT_STATE_CONTENT, diff --git a/core/feature_flag_list.py b/core/feature_flag_list.py index 419996e6f3362..9c129309eb8fe 100644 --- a/core/feature_flag_list.py +++ b/core/feature_flag_list.py @@ -141,7 +141,6 @@ class FeatureNames(enum.Enum): FeatureNames.ENABLE_CERTIFICATE_ASSESSMENT, FeatureNames.EXPLORATION_EDITOR_NEW_CREATOR_FEEDBACK_TAB, FeatureNames.TECHNICAL_FEEDBACK_DASHBOARD_ENABLED, - FeatureNames.STORY_EDITOR_ARCS, ] # Names of features in test stage, the corresponding feature flag instances must @@ -160,6 +159,7 @@ class FeatureNames(enum.Enum): FeatureNames.ENABLE_FINANCIAL_LITERACY_CAMPAIGN_BANNER_TEST_MODE, FeatureNames.WEB_FEEDBACK_MODAL_ENABLED, FeatureNames.ENABLE_TRANSLATION_OPPORTUNITIES_WITH_NEW_OPP_MODELS, + FeatureNames.STORY_EDITOR_ARCS, ] # Names of features in prod stage, the corresponding feature flag instances must @@ -393,7 +393,7 @@ class FeatureNames(enum.Enum): ( 'This flag enables arc-based chapter groupings in the story editor, ' 'allowing creators to organize chapters into named arcs.', - feature_flag_domain.ServerMode.DEV, + feature_flag_domain.ServerMode.TEST, ) ), } diff --git a/core/templates/app.constants.ts b/core/templates/app.constants.ts index 8ad8e758651f7..a65a4b8334366 100644 --- a/core/templates/app.constants.ts +++ b/core/templates/app.constants.ts @@ -325,8 +325,8 @@ export const AppConstants = { 'https://docs-google-com.translate.goog/forms/d/e/1FAIpQLSdL5mjFO7RxDtg8yfXluEtciYj8WnAqTL9fZWnwPgOqXV-9lg/viewform?_x_tr_sl=en&_x_tr_tl=', SUFFIX: '&_x_tr_hl=en-US&_x_tr_pto=wapp', }, - VOLUNTEER_FORM_LINK: - 'https://docs.google.com/forms/d/e/1FAIpQLSc5_rwUjugT_Jt_EB49_zAKWVY68I3fTXF5w9b5faIk7rL6yg/viewform', + VOLUNTEER_IDEALIST_LINK: + 'https://www.idealist.org/en/nonprofit/e436a3f9282f42439350aa6f0c335072-oppia-foundation-inc-sacramento', IMPACT_REPORT_LINK_2022: 'https://drive.google.com/file/d/1uRe145ou9Ka5O2duTB-N-i89NVPEtxh1/view', IMPACT_REPORT_LINK_2023: diff --git a/core/templates/base-components/feedback-modal.component.spec.ts b/core/templates/base-components/feedback-modal.component.spec.ts index cb94539213cc2..a5d8d7adad073 100644 --- a/core/templates/base-components/feedback-modal.component.spec.ts +++ b/core/templates/base-components/feedback-modal.component.spec.ts @@ -57,6 +57,11 @@ import {AlertsService} from 'services/alerts.service'; import {TranslateService} from '@ngx-translate/core'; import {MockTranslatePipe} from 'tests/unit-test-utils'; import {UserInfo} from 'domain/user/user-info.model'; +import { + MatBottomSheetRef, + MAT_BOTTOM_SHEET_DATA, +} from '@angular/material/bottom-sheet'; +import {Subject} from 'rxjs'; @Component({ selector: 'oppia-image-receiver', @@ -1410,3 +1415,105 @@ describe('FeedbackModalComponent', () => { } }); }); + +describe('FeedbackModalComponent in bottom sheet mode', () => { + let component: FeedbackModalComponent; + let fixture: ComponentFixture; + let bottomSheetRef: jasmine.SpyObj; + let keydownSubject: Subject; + + beforeEach(waitForAsync(() => { + keydownSubject = new Subject(); + bottomSheetRef = jasmine.createSpyObj('MatBottomSheetRef', [ + 'dismiss', + 'keydownEvents', + ]); + bottomSheetRef.keydownEvents.and.returnValue(keydownSubject.asObservable()); + + const translateServiceSpy = jasmine.createSpyObj('TranslateService', [ + 'instant', + ]); + translateServiceSpy.instant.and.callFake((key: string) => key); + + const alertServiceSpy = jasmine.createSpyObj('AlertsService', [ + 'addSuccessMessage', + 'addWarning', + ]); + + const feedbackSessionInfoServiceSpy = jasmine.createSpyObj( + 'FeedbackSessionInfoService', + ['getSessionInfo'] + ); + + const feedbackScreenshotStagingServiceSpy = jasmine.createSpyObj( + 'FeedbackScreenshotStagingService', + ['stageScreenshotAsync', 'clearStagedScreenshot'] + ); + + const insertScriptServiceSpy = jasmine.createSpyObj('InsertScriptService', [ + 'loadScript', + ]); + insertScriptServiceSpy.loadScript.and.returnValue(true); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, FormsModule, RouterTestingModule], + declarations: [ + FeedbackModalComponent, + MockImageReceiverComponent, + MockTranslatePipe, + ], + providers: [ + PageContextService, + PlayerPositionService, + LearnerAnswerInfoService, + FeedbackBackendApiService, + {provide: TranslateService, useValue: translateServiceSpy}, + {provide: AlertsService, useValue: alertServiceSpy}, + { + provide: FeedbackSessionInfoService, + useValue: feedbackSessionInfoServiceSpy, + }, + {provide: UserService, useClass: MockUserService}, + {provide: WindowRef, useClass: MockWindowRef}, + {provide: NgbActiveModal, useClass: MockActiveModal}, + { + provide: FeedbackScreenshotStagingService, + useValue: feedbackScreenshotStagingServiceSpy, + }, + {provide: InsertScriptService, useValue: insertScriptServiceSpy}, + {provide: MatBottomSheetRef, useValue: bottomSheetRef}, + { + provide: MAT_BOTTOM_SHEET_DATA, + useValue: {feedbackModalType: FeedbackModalType.LESSON_FEEDBACK}, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FeedbackModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should set the feedback modal type from the injected data', () => { + expect(component.feedbackModalType).toBe(FeedbackModalType.LESSON_FEEDBACK); + }); + + it('should dismiss the bottom sheet when Escape key is pressed', () => { + keydownSubject.next(new KeyboardEvent('keydown', {key: 'Escape'})); + expect(bottomSheetRef.dismiss).toHaveBeenCalled(); + }); + + it('should not dismiss the bottom sheet when a non-Escape key is pressed', () => { + keydownSubject.next(new KeyboardEvent('keydown', {key: 'Enter'})); + expect(bottomSheetRef.dismiss).not.toHaveBeenCalled(); + }); + + it('should dismiss the bottom sheet on closeModal', () => { + component.closeModal(); + expect(bottomSheetRef.dismiss).toHaveBeenCalled(); + }); +}); diff --git a/core/templates/base-components/feedback-modal.component.ts b/core/templates/base-components/feedback-modal.component.ts index 069f62b54ac7f..e376f7626e30b 100644 --- a/core/templates/base-components/feedback-modal.component.ts +++ b/core/templates/base-components/feedback-modal.component.ts @@ -19,8 +19,20 @@ * based on the provided feedback modal type. */ -import {Component, ElementRef, Input, OnInit, ViewChild} from '@angular/core'; +import { + Component, + ElementRef, + Input, + OnInit, + ViewChild, + Optional, + Inject, +} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import { + MatBottomSheetRef, + MAT_BOTTOM_SHEET_DATA, +} from '@angular/material/bottom-sheet'; import {WindowRef} from 'services/contextual/window-ref.service'; import {UserService} from 'services/user.service'; import {FeedbackScreenshotStagingService} from 'domain/feedback/feedback-screenshot-staging.service'; @@ -62,6 +74,10 @@ interface TurnstileWindow extends Window { turnstile?: TurnstileApi; } +interface FeedbackModalData { + feedbackModalType: FeedbackModalType; +} + @Component({ selector: 'oppia-feedback-modal', templateUrl: './feedback-modal.component.html', @@ -103,7 +119,12 @@ export class FeedbackModalComponent implements OnInit { private learnerAnswerInfoService: LearnerAnswerInfoService, private feedbackSessionInfoService: FeedbackSessionInfoService, private feedbackBackendApiService: FeedbackBackendApiService, - private ngbActiveModal: NgbActiveModal + @Optional() private ngbActiveModal: NgbActiveModal, + @Optional() + private feedbackBottomSheetRef?: MatBottomSheetRef, + @Optional() + @Inject(MAT_BOTTOM_SHEET_DATA) + private data?: FeedbackModalData ) {} get isLessonFeedbackMode(): boolean { @@ -176,6 +197,16 @@ export class FeedbackModalComponent implements OnInit { } async ngOnInit(): Promise { + if (this.data) { + this.feedbackModalType = this.data.feedbackModalType; + } + if (this.feedbackBottomSheetRef) { + this.feedbackBottomSheetRef.keydownEvents().subscribe(event => { + if (event.key === 'Escape') { + this.feedbackBottomSheetRef?.dismiss(); + } + }); + } this.showTechnicalLogsCheckbox = true; try { @@ -497,6 +528,10 @@ export class FeedbackModalComponent implements OnInit { this.captchaToken = ''; this.captchaSubmitError = null; this.removeScreenshot(); - this.ngbActiveModal.dismiss(); + if (this.feedbackBottomSheetRef) { + this.feedbackBottomSheetRef.dismiss(); + } else { + this.ngbActiveModal.dismiss(); + } } } diff --git a/core/templates/components/certificate-assessment-offering-helper/certificate-assessment-titled-shared-background-banner.component.html b/core/templates/components/certificate-assessment-offering-helper/certificate-assessment-titled-shared-background-banner.component.html index 0ec4c45db9404..4739ee38b26ff 100644 --- a/core/templates/components/certificate-assessment-offering-helper/certificate-assessment-titled-shared-background-banner.component.html +++ b/core/templates/components/certificate-assessment-offering-helper/certificate-assessment-titled-shared-background-banner.component.html @@ -4,7 +4,8 @@ diff --git a/core/templates/components/certificate-assessment-offering-helper/certificate-assessment-titled-shared-background-banner.component.ts b/core/templates/components/certificate-assessment-offering-helper/certificate-assessment-titled-shared-background-banner.component.ts index 06b9da5fd9334..00f299d7d8b6b 100644 --- a/core/templates/components/certificate-assessment-offering-helper/certificate-assessment-titled-shared-background-banner.component.ts +++ b/core/templates/components/certificate-assessment-offering-helper/certificate-assessment-titled-shared-background-banner.component.ts @@ -20,7 +20,7 @@ * page rather than centered in the remaining space next to the button. */ -import {Component, Input} from '@angular/core'; +import {Component, EventEmitter, Input, Output} from '@angular/core'; import './certificate-assessment-titled-shared-background-banner.component.css'; @Component({ @@ -35,4 +35,8 @@ export class CertificateAssessmentTitledBackgroundBannerComponent { @Input() title: string = ''; @Input() buttonText: string = 'I18N_CERTIFICATE_ASSESSMENT_EXIT_BUTTON'; @Input() buttonRoute: string[] = []; + // Emitted on every banner button click. Consumers that need custom behavior + // (such as returning to a stage instead of navigating) can subscribe to this + // output and leave buttonRoute empty so no navigation is triggered. + @Output() buttonClick = new EventEmitter(); } diff --git a/core/templates/components/common-layout-directives/common-elements/confirm-or-cancel-modal.component.spec.ts b/core/templates/components/common-layout-directives/common-elements/confirm-or-cancel-modal.component.spec.ts index c516903cae19f..15464348adef5 100644 --- a/core/templates/components/common-layout-directives/common-elements/confirm-or-cancel-modal.component.spec.ts +++ b/core/templates/components/common-layout-directives/common-elements/confirm-or-cancel-modal.component.spec.ts @@ -17,6 +17,8 @@ */ import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {MatBottomSheetRef} from '@angular/material/bottom-sheet'; +import {Subject} from 'rxjs'; import {ConfirmOrCancelModal} from './confirm-or-cancel-modal.component'; describe('Confirm Or Cancel Modal Component', () => { @@ -50,3 +52,44 @@ describe('Confirm Or Cancel Modal Component', () => { expect(dismissSpy).toHaveBeenCalledWith(message); }); }); + +describe('Confirm Or Cancel Modal Component in bottom sheet mode', () => { + let confirmOrCancelModal: ConfirmOrCancelModal; + let bottomSheetRef: jasmine.SpyObj; + let keydownSubject: Subject; + + beforeEach(() => { + keydownSubject = new Subject(); + bottomSheetRef = jasmine.createSpyObj('MatBottomSheetRef', [ + 'dismiss', + 'keydownEvents', + ]); + bottomSheetRef.keydownEvents.and.returnValue(keydownSubject.asObservable()); + confirmOrCancelModal = new ConfirmOrCancelModal( + null as unknown as NgbActiveModal, + bottomSheetRef + ); + }); + + it('should dismiss bottom sheet with the correct value on confirm', () => { + const message = 'closing'; + confirmOrCancelModal.confirm(message); + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith(message); + }); + + it('should dismiss bottom sheet with the correct value on cancel', () => { + const message = 'canceling'; + confirmOrCancelModal.cancel(message); + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith(message); + }); + + it('should dismiss bottom sheet when Escape key is pressed', () => { + keydownSubject.next(new KeyboardEvent('keydown', {key: 'Escape'})); + expect(bottomSheetRef.dismiss).toHaveBeenCalled(); + }); + + it('should not dismiss bottom sheet when a non-Escape key is pressed', () => { + keydownSubject.next(new KeyboardEvent('keydown', {key: 'Enter'})); + expect(bottomSheetRef.dismiss).not.toHaveBeenCalled(); + }); +}); diff --git a/core/templates/components/common-layout-directives/common-elements/confirm-or-cancel-modal.component.ts b/core/templates/components/common-layout-directives/common-elements/confirm-or-cancel-modal.component.ts index efde078f828e1..29579da98a5db 100644 --- a/core/templates/components/common-layout-directives/common-elements/confirm-or-cancel-modal.component.ts +++ b/core/templates/components/common-layout-directives/common-elements/confirm-or-cancel-modal.component.ts @@ -17,10 +17,23 @@ * dismiss. */ +import {Optional} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {MatBottomSheetRef} from '@angular/material/bottom-sheet'; export class ConfirmOrCancelModal { - constructor(protected modalInstance: NgbActiveModal) {} + constructor( + @Optional() protected modalInstance: NgbActiveModal, + @Optional() protected bottomSheetRef?: MatBottomSheetRef + ) { + if (this.bottomSheetRef) { + this.bottomSheetRef.keydownEvents().subscribe(event => { + if (event.key === 'Escape') { + this.bottomSheetRef?.dismiss(); + } + }); + } + } /** * Function called upon an affirmative user action. @@ -29,10 +42,18 @@ export class ConfirmOrCancelModal { * optional. */ confirm(value?: T): void { - this.modalInstance.close(value); + if (this.bottomSheetRef) { + this.bottomSheetRef.dismiss(value); + } else { + this.modalInstance.close(value); + } } cancel(value: T | 'cancel' = 'cancel'): void { - this.modalInstance.dismiss(value); + if (this.bottomSheetRef) { + this.bottomSheetRef.dismiss(value); + } else { + this.modalInstance.dismiss(value); + } } } diff --git a/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.css b/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.css index 9a0034b6fdade..5ce9bfac495e1 100644 --- a/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.css +++ b/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.css @@ -196,11 +196,9 @@ .oppia-feedback-detail-actions-bar { background-color: #fff; - bottom: 0; - margin-top: auto; + border-top: 1px solid #e5e5e5; + margin-top: 14px; padding-top: 14px; - position: sticky; - z-index: 10; } .oppia-feedback-detail-status-note { @@ -259,5 +257,55 @@ } .oppia-feedback-detail-replies-bar { - flex: 1; + flex: 0 0 auto; +} + +.oppia-feedback-response-empty { + color: #767676; + font-size: 14px; + font-style: italic; + line-height: 20px; +} + +.oppia-feedback-response-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.oppia-feedback-response { + background-color: #f8f9fa; + border: 1px solid #e0e0e0; + border-radius: 6px; + padding: 10px 12px; +} + +.oppia-feedback-response-header { + align-items: baseline; + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: space-between; + margin-bottom: 6px; +} + +.oppia-feedback-response-author { + color: #333; + font-size: 13px; + font-weight: 600; + line-height: 18px; +} + +.oppia-feedback-response-date { + color: #767676; + font-size: 12px; + line-height: 18px; +} + +.oppia-feedback-response-text { + color: #262626; + font-size: 14px; + line-height: 21px; + white-space: pre-wrap; + word-break: break-word; } diff --git a/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.html b/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.html index 57c10433e2a46..c22fc940b2abe 100644 --- a/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.html +++ b/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.html @@ -17,8 +17,8 @@

- @@ -39,25 +39,25 @@

Status {{ statusLabels[feedback.status] }} -
+
Category - {{ categoryLabels[feedback.category] }} + {{ categoryLabels[category] }}
Source - {{ sourceLabels[feedback.source] }} + {{ getFeedbackSourceLabel(feedback) }}
Platform - {{ getPlatformLabel(feedback.platform) }} + {{ getFeedbackPlatformLabel(feedback) }}
-
+
@@ -140,7 +140,7 @@

-
{{ feedback.report_message }}
+
{{ getFeedbackMessage() }}
[isCollapsible]="true" iconClass="fas fa-comments" class="oppia-feedback-detail-replies-bar"> + +
+ No replies yet. +
+ +
+
+ +
+ Creator + + {{ formatDate(response.responded_on) }} + +
+ +
+ {{ response.response_text }} +
+ +
+
+
diff --git a/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.spec.ts b/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.spec.ts index 1862267c2d49e..459fb0eae1726 100644 --- a/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.spec.ts +++ b/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.spec.ts @@ -30,13 +30,14 @@ import { FeedbackSessionInfo, ReportType, } from 'domain/feedback/feedback.model'; +import {LessonFeedbackDetailResponse} from '../../../domain/feedback/feedback.model'; describe('FeedbackDetailPageComponent', () => { let component: FeedbackDetailPageComponent; let fixture: ComponentFixture; let dateTimeFormatService: DateTimeFormatService; let windowRef: WindowRef; - const mockDetailResponse: PlatformFeedbackDetailResponse = { + const mockPlatformFeedbackDetailResponse: PlatformFeedbackDetailResponse = { id: 'report1', report_message: 'Sample report', source: ReportType.APP, @@ -59,6 +60,16 @@ describe('FeedbackDetailPageComponent', () => { state_index: 1, learner_current_answer: 'answer1', }; + const mockLessonFeedbackDetailresponse: LessonFeedbackDetailResponse = { + id: 'report1', + feedback_text: 'Sample report', + status: FeedbackStatus.OPEN, + lesson_metadata: mockLessonMetadata, + parent_feedback_id: null, + response_list: [], + unread_response_count: 0, + created_on_msecs: 1234567890, + }; const feedbackSessionInfo: FeedbackSessionInfo = { console_logs: [ @@ -114,7 +125,7 @@ describe('FeedbackDetailPageComponent', () => { userAgent: string ): URLSearchParams => { component.feedbackDetailResponse = { - ...mockDetailResponse, + ...mockPlatformFeedbackDetailResponse, session_info: { ...feedbackSessionInfo, environment: { @@ -151,12 +162,14 @@ describe('FeedbackDetailPageComponent', () => { }); it('should get correct Platform label', () => { - expect(component.getPlatformLabel(mockDetailResponse.platform)).toBe('Web'); + expect( + component.getPlatformLabel(mockPlatformFeedbackDetailResponse.platform) + ).toBe('Web'); expect(component.getPlatformLabel('android')).toBe('Android'); }); it('should format date correctly', () => { - const timestamp = mockDetailResponse.created_on_msecs; + const timestamp = mockPlatformFeedbackDetailResponse.created_on_msecs; const formattedDate = 'Jan 15, 1970, 11:56:07 PM'; spyOn( @@ -173,14 +186,14 @@ describe('FeedbackDetailPageComponent', () => { }); it('should return early the Reported Lesson URL if no lesson metadata', () => { - component.feedbackDetailResponse = mockDetailResponse; + component.feedbackDetailResponse = mockPlatformFeedbackDetailResponse; const url = component.getReportedLessonUrl(); expect(url).toBe(null); }); it('should construct and return the Reported Lesson URL', () => { component.feedbackDetailResponse = { - ...mockDetailResponse, + ...mockPlatformFeedbackDetailResponse, source: ReportType.LESSON, lesson_metadata: { ...mockLessonMetadata, @@ -192,14 +205,14 @@ describe('FeedbackDetailPageComponent', () => { }); it('should return early the Reported state editor URL if no lesson metadata', () => { - component.feedbackDetailResponse = mockDetailResponse; + component.feedbackDetailResponse = mockPlatformFeedbackDetailResponse; const url = component.getReportedStateEditorUrl(); expect(url).toBe(null); }); it('should construct and return the Reported state editor URL', () => { component.feedbackDetailResponse = { - ...mockDetailResponse, + ...mockPlatformFeedbackDetailResponse, source: ReportType.LESSON, lesson_metadata: { ...mockLessonMetadata, @@ -212,15 +225,20 @@ describe('FeedbackDetailPageComponent', () => { }); it('should get sessionInfo when sessionInfo is not null', () => { - component.feedbackDetailResponse = mockDetailResponse; + component.feedbackDetailResponse = mockPlatformFeedbackDetailResponse; expect(component.sessionInfo).toBe(null); component.feedbackDetailResponse = { - ...mockDetailResponse, + ...mockPlatformFeedbackDetailResponse, session_info: feedbackSessionInfo, }; expect(component.sessionInfo).toBe(feedbackSessionInfo); }); + it('should not get sessionInfo when feedbackDetailresponse is LessonFeedbackDetailResponse', () => { + component.feedbackDetailResponse = mockLessonFeedbackDetailresponse; + expect(component.sessionInfo).toBe(null); + }); + it('should get correct category label', () => { const category = ReportAnIssueCategory.BROKEN_LAYOUT_OR_IMAGE; expect(component.getCategoryLabel(category)).toBe('Broken Layout / Image'); @@ -235,7 +253,7 @@ describe('FeedbackDetailPageComponent', () => { it('should get correct source label', () => { const source = ReportType.APP; expect(component.getSourceLabel(source)).toBe('App'); - expect(component.getSourceLabel('new_source')).toBe('new_source'); + expect(component.getSourceLabel('new_source')).toBe('Lesson'); }); it('should get correct destination label', () => { @@ -262,7 +280,7 @@ describe('FeedbackDetailPageComponent', () => { const statusChangeSpy = spyOn(component.statusChange, 'emit'); const githubTransferSpy = spyOn(component.githubTransfer, 'emit'); component.feedbackDetailResponse = { - ...mockDetailResponse, + ...mockPlatformFeedbackDetailResponse, category: null, page_url: '', }; @@ -307,7 +325,7 @@ describe('FeedbackDetailPageComponent', () => { 'getLocaleAbbreviatedDatetimeString' ).and.returnValue(formattedDate); component.feedbackDetailResponse = { - ...mockDetailResponse, + ...mockPlatformFeedbackDetailResponse, session_info: feedbackSessionInfo, lesson_metadata: mockLessonMetadata, screenshot_filename: 'screenshot', @@ -443,7 +461,88 @@ describe('FeedbackDetailPageComponent', () => { }); }); - it('should return without performing any action when reply is sent', () => { - expect(component.onReplySend()).toBeUndefined(); + it('should emit the reply and clear reply text', () => { + const replySpy = spyOn(component.messageSend, 'emit'); + component.replyText = 'Looking into this'; + component.onReplySend(); + expect(replySpy).toHaveBeenCalledWith('Looking into this'); + expect(component.replyText).toBe(''); + }); + + it('should not emit empty reply text', () => { + const replySpy = spyOn(component.messageSend, 'emit'); + component.replyText = ''; + component.onReplySend(); + expect(replySpy).not.toHaveBeenCalledWith(''); + }); + + it('should return the right category depending on the feedbackResponse', () => { + expect( + component.getFeedbackCategory(mockLessonFeedbackDetailresponse) + ).toBeNull(); + expect( + component.getFeedbackCategory(mockPlatformFeedbackDetailResponse) + ).toBe(ReportAnIssueCategory.OTHER_OR_NOT_SURE); + }); + + it('should return the right source label depending on the feedbackResponse', () => { + expect( + component.getFeedbackSourceLabel(mockLessonFeedbackDetailresponse) + ).toBe('Lesson'); + expect( + component.getFeedbackSourceLabel(mockPlatformFeedbackDetailResponse) + ).toBe('App'); + }); + + it('should return the right platform label depending on the feedbackResponse', () => { + expect( + component.getFeedbackPlatformLabel(mockLessonFeedbackDetailresponse) + ).toBe('Web'); + expect( + component.getFeedbackPlatformLabel(mockPlatformFeedbackDetailResponse) + ).toBe('Web'); + }); + + it('should return the page_url depending on the feedbackResponse', () => { + expect( + component.getFeedbackPageUrl(mockLessonFeedbackDetailresponse) + ).toBeNull(); + expect( + component.getFeedbackPageUrl(mockPlatformFeedbackDetailResponse) + ).toBe(mockPlatformFeedbackDetailResponse.page_url); + }); + + it('should return the right feedback responses depending on the feedbackResponse', () => { + expect( + component.getFeedbackResponses(mockLessonFeedbackDetailresponse) + ).toBe(mockLessonFeedbackDetailresponse.response_list); + expect( + component.getFeedbackResponses(mockPlatformFeedbackDetailResponse) + ).toEqual([]); + }); + + it('should return empty string if feedbackDetailresponse is null', () => { + component.feedbackDetailResponse = null; + expect(component.getFeedbackMessage()).toBe(''); + }); + + it('should return report_message if feedbackDetailresponse is platformFeedbackdetailResponse', () => { + component.feedbackDetailResponse = mockPlatformFeedbackDetailResponse; + expect(component.getFeedbackMessage()).toBe( + mockPlatformFeedbackDetailResponse.report_message + ); + }); + + it('should return feedback_text if feedbackDetailresponse is LessonFeedbackDetailResponse', () => { + component.feedbackDetailResponse = mockLessonFeedbackDetailresponse; + expect(component.getFeedbackMessage()).toBe( + mockLessonFeedbackDetailresponse.feedback_text + ); + }); + + it('should return empty string for getGithubIssueUrl if detailResponse is LessonfeedbackDetailresponse', () => { + component.feedbackDetailResponse = mockLessonFeedbackDetailresponse; + fixture.detectChanges(); + expect(component.getGithubIssueUrl()).toBe(''); }); }); diff --git a/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.ts b/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.ts index 299770a47f9f9..d9f1799144c50 100644 --- a/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.ts +++ b/core/templates/components/feedback-shared/feedback-detail-page/feedback-detail-page.component.ts @@ -30,7 +30,11 @@ import { FeedbackCardConfig, FeedbackSessionInfo, FeedbackStatus, + LessonFeedbackResponse, + LessonFeedbackDetailResponse, PlatformFeedbackDetailResponse, + ReportAnIssueCategory, + ReportType, SOURCE_LABELS, TECHNICAL_TEAM_LABELS, } from 'domain/feedback/feedback.model'; @@ -53,25 +57,29 @@ export class FeedbackDetailPageComponent { private dateTimeFormatService: DateTimeFormatService, private windowRef: WindowRef ) {} - @Input() feedbackDetailResponse!: PlatformFeedbackDetailResponse; + @Input() feedbackDetailResponse: + | LessonFeedbackDetailResponse + | PlatformFeedbackDetailResponse + | null = null; @Input() feedbackDetailPageConfig!: FeedbackCardConfig; @Input() screenshotDataUrl: string | null = null; + @Input() statusOptions!: FeedbackStatus[]; @Output() goBack = new EventEmitter(); @Output() statusChange = new EventEmitter(); + @Output() messageSend = new EventEmitter(); @Output() githubTransfer = new EventEmitter(); readonly categoryLabels = CATEGORY_LABELS; readonly statusLabels = FEEDBACK_STATUS_LABELS; readonly sourceLabels = SOURCE_LABELS; readonly teamLabels = TECHNICAL_TEAM_LABELS; - readonly statusOptions = Object.values(FeedbackStatus); readonly transferredToGithubStatus = FeedbackStatus.TRANSFERRED_TO_GITHUB; replyText: string = ''; isSendingReply: boolean = false; - getPlatformLabel(platform: string): string { - return platform === 'web' ? 'Web' : 'Android'; + getPlatformLabel(platform: string | null): string { + return platform === 'android' ? 'Android' : 'Web'; } formatDate(timestamp: number): string { @@ -104,7 +112,10 @@ export class FeedbackDetailPageComponent { } get sessionInfo(): FeedbackSessionInfo | null { - return this.feedbackDetailResponse?.session_info ?? null; + if (this.isPlatformFeedbackDetailResponse(this.feedbackDetailResponse)) { + return this.feedbackDetailResponse.session_info; + } + return null; } getCategoryLabel(category: string | null): string { @@ -115,7 +126,47 @@ export class FeedbackDetailPageComponent { } getSourceLabel(source: string): string { - return this.sourceLabels[source] || source; + return this.sourceLabels[source] || 'Lesson'; + } + + getFeedbackCategory( + response: LessonFeedbackDetailResponse | PlatformFeedbackDetailResponse + ): ReportAnIssueCategory | null { + return this.isPlatformFeedbackDetailResponse(response) + ? response.category + : null; + } + + getFeedbackSourceLabel( + response: LessonFeedbackDetailResponse | PlatformFeedbackDetailResponse + ): string { + return this.isPlatformFeedbackDetailResponse(response) + ? this.getSourceLabel(response.source) + : this.getSourceLabel(ReportType.LESSON); + } + + getFeedbackPlatformLabel( + response: LessonFeedbackDetailResponse | PlatformFeedbackDetailResponse + ): string { + return this.getPlatformLabel( + this.isPlatformFeedbackDetailResponse(response) ? response.platform : null + ); + } + + getFeedbackPageUrl( + response: LessonFeedbackDetailResponse | PlatformFeedbackDetailResponse + ): string | null { + return this.isPlatformFeedbackDetailResponse(response) + ? response.page_url + : null; + } + + getFeedbackResponses( + response: LessonFeedbackDetailResponse | PlatformFeedbackDetailResponse + ): LessonFeedbackResponse[] { + return this.isPlatformFeedbackDetailResponse(response) + ? [] + : response.response_list; } getDestinationLabel( @@ -126,6 +177,20 @@ export class FeedbackDetailPageComponent { : this.teamLabels[destinationDashboard]; } + getFeedbackMessage(): string { + const response = this.feedbackDetailResponse; + + if (response === null) { + return ''; + } + + if (this.isPlatformFeedbackDetailResponse(response)) { + return response.report_message; + } + + return response.feedback_text; + } + onStatusOptionClick(status: FeedbackStatus): void { if (status === FeedbackStatus.TRANSFERRED_TO_GITHUB) { this.githubTransfer.emit(this.getGithubIssueUrl()); @@ -135,8 +200,20 @@ export class FeedbackDetailPageComponent { this.statusChange.emit(status); } + private isPlatformFeedbackDetailResponse( + response: + | PlatformFeedbackDetailResponse + | LessonFeedbackDetailResponse + | null + ): response is PlatformFeedbackDetailResponse { + return response !== null && 'report_message' in response; + } + getGithubIssueUrl(): string { const response = this.feedbackDetailResponse; + if (!this.isPlatformFeedbackDetailResponse(response)) { + return ''; + } const title = response ? `[BUG]: User feedback report: ${this.getCategoryLabel( response.category @@ -145,29 +222,44 @@ export class FeedbackDetailPageComponent { const params = new URLSearchParams(); params.append('template', '6_technical_feedback_report.yml'); params.append('title', title); - params.append('describe-the-bug', this.getGithubIssueDescription()); + params.append('describe-the-bug', this.getGithubIssueDescription(response)); params.append('page-url', response?.page_url || 'Not provided'); - params.append('steps-to-reproduce', this.getGithubIssueSteps()); - params.append('expected-behavior', this.getGithubIssueExpectedBehavior()); - params.append('screenshots-videos', this.getGithubIssueScreenshotDetails()); - params.append('device', this.getGithubIssueDevice()); - params.append('operating-system', this.getGithubIssueOperatingSystem()); - params.append('browsers', this.getGithubIssueBrowserName()); - params.append('browser-version', this.getGithubIssueBrowserVersion()); - params.append('additional-context', this.getGithubIssueAdditionalContext()); + params.append('steps-to-reproduce', this.getGithubIssueSteps(response)); + params.append( + 'expected-behavior', + this.getGithubIssueExpectedBehavior(response) + ); + params.append( + 'screenshots-videos', + this.getGithubIssueScreenshotDetails(response) + ); + params.append('device', this.getGithubIssueDevice(response)); + params.append( + 'operating-system', + this.getGithubIssueOperatingSystem(response) + ); + params.append('browsers', this.getGithubIssueBrowserName(response)); + params.append( + 'browser-version', + this.getGithubIssueBrowserVersion(response) + ); + params.append( + 'additional-context', + this.getGithubIssueAdditionalContext(response) + ); return `https://github.com/oppia/oppia/issues/new?${params.toString()}`; } - private getGithubIssueDescription(): string { - const response = this.feedbackDetailResponse; - + private getGithubIssueDescription( + response: PlatformFeedbackDetailResponse + ): string { return [ response.report_message, '', 'Transferred from the Oppia Technical feedback dashboard.', `Report ID: ${response.id}`, - `Feedback report: ${this.getFeedbackReportUrl()}`, + `Feedback report: ${this.getFeedbackReportUrl(response)}`, `Submitted: ${this.formatDate(response.created_on_msecs)}`, `Source: ${this.getSourceLabel(response.source)}`, `Category: ${this.getCategoryLabel(response.category)}`, @@ -176,8 +268,9 @@ export class FeedbackDetailPageComponent { ].join('\n'); } - private getFeedbackReportUrl(): string { - const response = this.feedbackDetailResponse; + private getFeedbackReportUrl( + response: PlatformFeedbackDetailResponse + ): string { const reportPath = `/technical-feedback-dashboard/${encodeURIComponent( response.destination_dashboard )}/${encodeURIComponent(response.id)}`; @@ -185,9 +278,9 @@ export class FeedbackDetailPageComponent { return `${this.windowRef.nativeWindow.location.origin}${reportPath}`; } - private getGithubIssueSteps(): string { - const response = this.feedbackDetailResponse; - + private getGithubIssueSteps( + response: PlatformFeedbackDetailResponse + ): string { const issueLines = [ '1. Review the transferred feedback report details.', `2. Open the reported page: ${response.page_url || 'Not provided'}`, @@ -209,12 +302,15 @@ export class FeedbackDetailPageComponent { return issueLines.join('\n'); } - private getGithubIssueExpectedBehavior(): string { + private getGithubIssueExpectedBehavior( + response: PlatformFeedbackDetailResponse + ): string { return 'The reported user-facing problem should not occur.'; } - private getGithubIssueScreenshotDetails(): string { - const response = this.feedbackDetailResponse; + private getGithubIssueScreenshotDetails( + response: PlatformFeedbackDetailResponse + ): string { if (!response.screenshot_filename) { return 'No screenshot was attached to this report.'; } @@ -232,9 +328,9 @@ export class FeedbackDetailPageComponent { ].join('\n'); } - private getGithubIssueAdditionalContext(): string { - const response = this.feedbackDetailResponse; - + private getGithubIssueAdditionalContext( + response: PlatformFeedbackDetailResponse + ): string { return [ '## Feedback metadata', '', @@ -252,21 +348,27 @@ export class FeedbackDetailPageComponent { '## Session logs', '', '```json', - this.getGithubIssueSessionLogJson(), + this.getGithubIssueSessionLogJson(response), '```', ].join('\n'); } - private getGithubIssueBrowserVersion(): string { - return this.getBrowserDetailsFromUserAgent().version; + private getGithubIssueBrowserVersion( + response: PlatformFeedbackDetailResponse + ): string { + return this.getBrowserDetailsFromUserAgent(response).version; } - private getGithubIssueBrowserName(): string { - return this.getBrowserDetailsFromUserAgent().name; + private getGithubIssueBrowserName( + response: PlatformFeedbackDetailResponse + ): string { + return this.getBrowserDetailsFromUserAgent(response).name; } - private getGithubIssueOperatingSystem(): string { - const userAgent = this.getUserAgent(); + private getGithubIssueOperatingSystem( + response: PlatformFeedbackDetailResponse + ): string { + const userAgent = this.getUserAgent(response); if (!userAgent) { return 'Other'; } @@ -294,8 +396,10 @@ export class FeedbackDetailPageComponent { return 'Other'; } - private getGithubIssueDevice(): string { - const userAgent = this.getUserAgent(); + private getGithubIssueDevice( + response: PlatformFeedbackDetailResponse + ): string { + const userAgent = this.getUserAgent(response); if (!userAgent) { return 'Desktop'; } @@ -305,8 +409,10 @@ export class FeedbackDetailPageComponent { : 'Desktop'; } - private getBrowserDetailsFromUserAgent(): BrowserDetails { - const userAgent = this.getUserAgent(); + private getBrowserDetailsFromUserAgent( + response: PlatformFeedbackDetailResponse + ): BrowserDetails { + const userAgent = this.getUserAgent(response); if (!userAgent) { return { name: 'Other', @@ -352,14 +458,16 @@ export class FeedbackDetailPageComponent { }; } - private getUserAgent(): string | null { - return ( - this.feedbackDetailResponse?.session_info?.environment?.user_agent ?? null - ); + private getUserAgent( + response: PlatformFeedbackDetailResponse + ): string | null { + return response?.session_info?.environment.user_agent ?? null; } - private getGithubIssueSessionLogJson(): string { - const sessionInfo = this.feedbackDetailResponse?.session_info; + private getGithubIssueSessionLogJson( + response: PlatformFeedbackDetailResponse + ): string { + const sessionInfo = response?.session_info; if (!sessionInfo) { return 'No session logs were attached to this report.'; } @@ -367,9 +475,14 @@ export class FeedbackDetailPageComponent { return JSON.stringify(sessionInfo, null, 2) ?? 'Unable to serialize logs.'; } - // TODO(#24716): Stub right now, will be done in the creator feedback tab and - // My suggestions tab's PR. onReplySend(): void { - return; + const replyText = this.replyText.trim(); + if (!replyText) { + return; + } + this.isSendingReply = true; + this.messageSend.emit(replyText); + this.replyText = ''; + this.isSendingReply = false; } } diff --git a/core/templates/components/feedback-shared/feedback-filter-bar/feedback-filter-bar.component.html b/core/templates/components/feedback-shared/feedback-filter-bar/feedback-filter-bar.component.html index 0e67f33c102a4..60566e2335085 100644 --- a/core/templates/components/feedback-shared/feedback-filter-bar/feedback-filter-bar.component.html +++ b/core/templates/components/feedback-shared/feedback-filter-bar/feedback-filter-bar.component.html @@ -21,6 +21,16 @@
+
+ Feedback Type: + +
+ - diff --git a/core/templates/pages/exploration-editor-page/modal-templates/help-modal.component.ts b/core/templates/pages/exploration-editor-page/modal-templates/help-modal.component.ts index 7f3746bab7618..62499519e6f66 100644 --- a/core/templates/pages/exploration-editor-page/modal-templates/help-modal.component.ts +++ b/core/templates/pages/exploration-editor-page/modal-templates/help-modal.component.ts @@ -20,10 +20,12 @@ import {Component, OnInit} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {PageContextService} from 'services/page-context.service'; import {SiteAnalyticsService} from 'services/site-analytics.service'; +import './help-modal.component.css'; @Component({ selector: 'oppia-help-modal', templateUrl: './help-modal.component.html', + styleUrls: ['./help-modal.component.css'], }) export class HelpModalComponent implements OnInit { EDITOR_TUTORIAL_MODE: string = 'editor'; diff --git a/core/templates/pages/exploration-editor-page/modal-templates/metadata-version-history-modal.component.css b/core/templates/pages/exploration-editor-page/modal-templates/metadata-version-history-modal.component.css new file mode 100644 index 0000000000000..8a7856ce592d8 --- /dev/null +++ b/core/templates/pages/exploration-editor-page/modal-templates/metadata-version-history-modal.component.css @@ -0,0 +1,68 @@ +.CodeMirror-merge-copy { + display: none; +} +.CodeMirror-merge-r-deleted { + text-decoration: underline #90ee90 2px; + text-decoration-skip-ink: none; +} +.CodeMirror-merge-r-inserted { + text-decoration: underline red 2px; + text-decoration-skip-ink: none; +} +.CodeMirror-merge, .CodeMirror-merge .CodeMirror { + height: 55vh; +} +.state-diff-modal .modal-dialog { + max-width: 1200px; + width: 90%; +} +.modal-body { + height: 405px; + margin-top: -30px; + overflow: auto; +} +.colgroup-1 { + padding: 15px; +} +.colgroup-3 { + padding: 15px; +} +.state-diff-modal .colgroup-1 { + width: 47%; + word-wrap: break-word; +} +.state-diff-modal .colgroup-2 { + width: 6%; +} +.state-diff-modal .colgroup-3 { + width: 47%; + word-wrap: break-word; +} +.state-diff-modal .oppia-click-arrows-text { + margin-bottom: 20px; +} +.interstitial-loading { + align-items: center; + display: flex; + height: 100%; + justify-content: center; + width: 100%; +} +.spinner-border { + height: 4rem; + width: 4rem; +} +.error-message { + color: #f00; + display: inline-block; + font-size: 1.3rem; + max-width: 250px; + padding: 10px; +} +.modal-footer { + display: flex; + justify-content: flex-start; +} +.next-commit { + margin-left: auto; +} diff --git a/core/templates/pages/exploration-editor-page/modal-templates/metadata-version-history-modal.component.html b/core/templates/pages/exploration-editor-page/modal-templates/metadata-version-history-modal.component.html index 395bdf57e8d5d..d34b1d52583b3 100644 --- a/core/templates/pages/exploration-editor-page/modal-templates/metadata-version-history-modal.component.html +++ b/core/templates/pages/exploration-editor-page/modal-templates/metadata-version-history-modal.component.html @@ -35,74 +35,3 @@

- - diff --git a/core/templates/pages/exploration-editor-page/modal-templates/metadata-version-history-modal.component.ts b/core/templates/pages/exploration-editor-page/modal-templates/metadata-version-history-modal.component.ts index 823075e8f8772..766741401d40b 100644 --- a/core/templates/pages/exploration-editor-page/modal-templates/metadata-version-history-modal.component.ts +++ b/core/templates/pages/exploration-editor-page/modal-templates/metadata-version-history-modal.component.ts @@ -25,6 +25,7 @@ import {PageContextService} from 'services/page-context.service'; import {HistoryTabYamlConversionService} from '../services/history-tab-yaml-conversion.service'; import {VersionHistoryBackendApiService} from '../services/version-history-backend-api.service'; import {VersionHistoryService} from '../services/version-history.service'; +import './metadata-version-history-modal.component.css'; interface HeadersAndYamlStrs { previousVersionMetadataYaml: string; @@ -41,6 +42,7 @@ interface MergeviewOptions { @Component({ selector: 'oppia-metadata-version-history', templateUrl: './metadata-version-history-modal.component.html', + styleUrls: ['./metadata-version-history-modal.component.css'], }) export class MetadataVersionHistoryModalComponent extends ConfirmOrCancelModal diff --git a/core/templates/pages/exploration-editor-page/modal-templates/post-publish-modal.component.css b/core/templates/pages/exploration-editor-page/modal-templates/post-publish-modal.component.css new file mode 100644 index 0000000000000..482dae214d47f --- /dev/null +++ b/core/templates/pages/exploration-editor-page/modal-templates/post-publish-modal.component.css @@ -0,0 +1,64 @@ +.oppia-share-publish-modal .oppia-share-publish-body, +.oppia-share-publish-body p { + text-align: center; +} + +.oppia-share-publish-modal .oppia-share-publish-close { + background-color: #015c53; + border-radius: 4px; + color: #fff; + font-family: "Roboto", Arial, sans-serif; + font-size: 14px; + margin-top: 10px; + text-align: center; + text-transform: uppercase; + width: 200px; +} + +.oppia-share-publish-modal .oppia-share-publish-close:hover { + background-color: #004d45; + color: #fff; +} + +.oppia-share-publish-modal .oppia-share-publish-header { + background: #009688; + border-radius: 5px 5px 0 0; + text-align: center; +} + +.oppia-share-publish-modal .oppia-share-publish-header h3 { + color: #fff; + font-size: 1.3em; +} + +.oppia-share-publish-modal .oppia-share-publish-header img { + width: 40%; +} + +.oppia-share-publish-body .oppia-share-publish-link { + display: inline; +} + +.oppia-share-publish-body .oppia-share-publish-link .oppia-share-link-container { + align-items: flex-end; + display: flex; + justify-content: space-evenly; + margin: 10px; +} + +.oppia-share-publish-body .oppia-share-publish-link div { + border-radius: 4px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.oppia-share-publish-body .oppia-share-publish-link span { + left: 481px; + position: absolute; + top: 124px; +} + +.oppia-share-publish-body .oppia-share-publish-link i { + color: #3f9187; + padding-left: 6px; +} diff --git a/core/templates/pages/exploration-editor-page/modal-templates/post-publish-modal.component.html b/core/templates/pages/exploration-editor-page/modal-templates/post-publish-modal.component.html index e48b4789239f0..e051eb147e050 100644 --- a/core/templates/pages/exploration-editor-page/modal-templates/post-publish-modal.component.html +++ b/core/templates/pages/exploration-editor-page/modal-templates/post-publish-modal.component.html @@ -23,70 +23,3 @@

Awesome!

- - diff --git a/core/templates/pages/exploration-editor-page/modal-templates/post-publish-modal.component.ts b/core/templates/pages/exploration-editor-page/modal-templates/post-publish-modal.component.ts index 84629cc65e749..8608501e0e325 100644 --- a/core/templates/pages/exploration-editor-page/modal-templates/post-publish-modal.component.ts +++ b/core/templates/pages/exploration-editor-page/modal-templates/post-publish-modal.component.ts @@ -22,10 +22,12 @@ import {UrlInterpolationService} from 'domain/utilities/url-interpolation.servic import {PageContextService} from 'services/page-context.service'; import {WindowRef} from 'services/contextual/window-ref.service'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; +import './post-publish-modal.component.css'; @Component({ selector: 'oppia-post-publish-modal', templateUrl: './post-publish-modal.component.html', + styleUrls: ['./post-publish-modal.component.css'], }) export class PostPublishModalComponent extends ConfirmOrCancelModal diff --git a/core/templates/pages/exploration-editor-page/modal-templates/state-diff-modal.component.css b/core/templates/pages/exploration-editor-page/modal-templates/state-diff-modal.component.css new file mode 100644 index 0000000000000..c7a8294f7f808 --- /dev/null +++ b/core/templates/pages/exploration-editor-page/modal-templates/state-diff-modal.component.css @@ -0,0 +1,36 @@ +.CodeMirror-merge-copy { + display: none; +} +.CodeMirror-merge-r-deleted { + text-decoration: underline #90ee90 2px; + text-decoration-skip-ink: none; +} +.CodeMirror-merge-r-inserted { + text-decoration: underline red 2px; + text-decoration-skip-ink: none; +} +.CodeMirror-merge, .CodeMirror-merge .CodeMirror { + height: 55vh; +} +.state-diff-modal .modal-dialog { + max-width: 1200px; + width: 90%; +} +.state-diff-modal .modal-body { + height: 60vh; + margin-top: -30px; +} +.state-diff-modal .colgroup-1 { + width: 47%; + word-wrap: break-word; +} +.state-diff-modal .colgroup-2 { + width: 6%; +} +.state-diff-modal .colgroup-3 { + width: 47%; + word-wrap: break-word; +} +.state-diff-modal .oppia-click-arrows-text { + margin-bottom: 20px; +} diff --git a/core/templates/pages/exploration-editor-page/modal-templates/state-diff-modal.component.html b/core/templates/pages/exploration-editor-page/modal-templates/state-diff-modal.component.html index 38604c8266274..2cb68be462090 100644 --- a/core/templates/pages/exploration-editor-page/modal-templates/state-diff-modal.component.html +++ b/core/templates/pages/exploration-editor-page/modal-templates/state-diff-modal.component.html @@ -42,42 +42,3 @@

Done

- - diff --git a/core/templates/pages/exploration-editor-page/modal-templates/state-diff-modal.component.ts b/core/templates/pages/exploration-editor-page/modal-templates/state-diff-modal.component.ts index 23acea1eb4074..c407616892712 100644 --- a/core/templates/pages/exploration-editor-page/modal-templates/state-diff-modal.component.ts +++ b/core/templates/pages/exploration-editor-page/modal-templates/state-diff-modal.component.ts @@ -23,6 +23,7 @@ import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-e import {State} from 'domain/state/state.model'; import {HistoryTabYamlConversionService} from '../services/history-tab-yaml-conversion.service'; import {EntityTranslationsService} from 'services/entity-translations.services'; +import './state-diff-modal.component.css'; export interface headersAndYamlStrs { leftPane: string; @@ -39,6 +40,7 @@ interface mergeviewOptions { @Component({ selector: 'oppia-state-diff', templateUrl: './state-diff-modal.component.html', + styleUrls: ['./state-diff-modal.component.css'], }) export class StateDiffModalComponent extends ConfirmOrCancelModal diff --git a/core/templates/pages/exploration-editor-page/modal-templates/state-version-history-modal.component.css b/core/templates/pages/exploration-editor-page/modal-templates/state-version-history-modal.component.css new file mode 100644 index 0000000000000..d6a64251515f8 --- /dev/null +++ b/core/templates/pages/exploration-editor-page/modal-templates/state-version-history-modal.component.css @@ -0,0 +1,50 @@ +.CodeMirror-merge-copy { + display: none; +} +.CodeMirror-merge-r-deleted { + text-decoration: underline #90ee90 2px; + text-decoration-skip-ink: none; +} +.CodeMirror-merge-r-inserted { + text-decoration: underline #f00 2px; + text-decoration-skip-ink: none; +} +.CodeMirror-merge, .CodeMirror-merge .CodeMirror { + height: 55vh; +} +.modal-body { + height: 405px; + margin-top: -30px; + overflow: auto; +} +.colgroup-1 { + padding: 15px; +} +.colgroup-3 { + padding: 15px; +} +.interstitial-loading { + align-items: center; + display: flex; + height: 100%; + justify-content: center; + width: 100%; +} +.spinner-border { + height: 4rem; + width: 4rem; +} +.error-message { + color: #f00; + display: inline-block; + font-size: 1.3rem; + max-width: 250px; + padding: 10px; +} +.modal-footer { + display: flex; + justify-content: flex-start; +} +.next-commit { + margin-left: auto; +} diff --git a/core/templates/pages/exploration-editor-page/modal-templates/state-version-history-modal.component.html b/core/templates/pages/exploration-editor-page/modal-templates/state-version-history-modal.component.html index 9512196b36171..0089298eeeaf3 100644 --- a/core/templates/pages/exploration-editor-page/modal-templates/state-version-history-modal.component.html +++ b/core/templates/pages/exploration-editor-page/modal-templates/state-version-history-modal.component.html @@ -53,56 +53,3 @@

- - diff --git a/core/templates/pages/exploration-editor-page/modal-templates/state-version-history-modal.component.ts b/core/templates/pages/exploration-editor-page/modal-templates/state-version-history-modal.component.ts index 07fd19d8cf255..05aa78b6e0eee 100644 --- a/core/templates/pages/exploration-editor-page/modal-templates/state-version-history-modal.component.ts +++ b/core/templates/pages/exploration-editor-page/modal-templates/state-version-history-modal.component.ts @@ -25,6 +25,7 @@ import {PageContextService} from 'services/page-context.service'; import {HistoryTabYamlConversionService} from '../services/history-tab-yaml-conversion.service'; import {VersionHistoryBackendApiService} from '../services/version-history-backend-api.service'; import {VersionHistoryService} from '../services/version-history.service'; +import './state-version-history-modal.component.css'; interface HeadersAndYamlStrs { previousVersionStateYaml: string; @@ -41,6 +42,7 @@ interface MergeviewOptions { @Component({ selector: 'oppia-state-version-history-modal', templateUrl: './state-version-history-modal.component.html', + styleUrls: ['./state-version-history-modal.component.css'], }) export class StateVersionHistoryModalComponent extends ConfirmOrCancelModal diff --git a/core/templates/pages/exploration-editor-page/param-changes-editor/param-changes-editor.component.css b/core/templates/pages/exploration-editor-page/param-changes-editor/param-changes-editor.component.css new file mode 100644 index 0000000000000..c53327a33efce --- /dev/null +++ b/core/templates/pages/exploration-editor-page/param-changes-editor/param-changes-editor.component.css @@ -0,0 +1,40 @@ +.oppia-param-editor-row { + margin-bottom: 15px; + position: relative; +} +.oppia-param-display-row { + margin-bottom: 4px; +} +.oppia-param-change-sort-handle { + cursor: move; + left: -20px; + opacity: 0.3; + position: absolute; + top: 4px; +} +.oppia-delete-param-change-button { + background: none; + border: 0; + color: #000; + cursor: pointer; + height: 30px; + opacity: 0.5; + position: absolute; + right: -30px; + top: 0; + width: 30px; +} +.oppia-delete-param-change-button:hover { + opacity: 1; +} +.oppia-form-control { + display: inline; + width: 110px; +} +.oppia-serious-warning-text-container { + margin-bottom: 20px; + margin-top: 20px; +} +.oppia-card-parameter { + opacity: 0.7; +} diff --git a/core/templates/pages/exploration-editor-page/param-changes-editor/param-changes-editor.component.html b/core/templates/pages/exploration-editor-page/param-changes-editor/param-changes-editor.component.html index 65bf4d811b42f..6ef38be7bc593 100644 --- a/core/templates/pages/exploration-editor-page/param-changes-editor/param-changes-editor.component.html +++ b/core/templates/pages/exploration-editor-page/param-changes-editor/param-changes-editor.component.html @@ -1,45 +1,3 @@ -
- - diff --git a/core/templates/pages/exploration-editor-page/preview-tab/preview-tab.component.ts b/core/templates/pages/exploration-editor-page/preview-tab/preview-tab.component.ts index 35cc64f750617..b43c5315bd374 100644 --- a/core/templates/pages/exploration-editor-page/preview-tab/preview-tab.component.ts +++ b/core/templates/pages/exploration-editor-page/preview-tab/preview-tab.component.ts @@ -48,10 +48,12 @@ import {ExplorationChangeEditVoiceovers} from 'domain/exploration/exploration-dr import {ChangeListService} from '../services/change-list.service'; import {EntityVoiceovers} from 'domain/voiceover/entity-voiceovers.model'; import {Voiceover} from 'domain/exploration/voiceover.model'; +import './preview-tab.component.css'; @Component({ selector: 'oppia-preview-tab', templateUrl: './preview-tab.component.html', + styleUrls: ['./preview-tab.component.css'], }) export class PreviewTabComponent implements OnInit, OnDestroy { directiveSubscriptions = new Subscription(); diff --git a/core/templates/pages/exploration-editor-page/preview-tab/templates/preview-set-parameters-modal.component.css b/core/templates/pages/exploration-editor-page/preview-tab/templates/preview-set-parameters-modal.component.css new file mode 100644 index 0000000000000..4e30588788689 --- /dev/null +++ b/core/templates/pages/exploration-editor-page/preview-tab/templates/preview-set-parameters-modal.component.css @@ -0,0 +1,9 @@ +.oppia-preview-set-params-modal { + padding: 15px 10px 15px 10px; +} +.oppia-preview-set-params-modal .oppia-preview-set-params-modal-table { + margin: 5px 0 15px 0 +} +.oppia-preview-set-params-modal .oppia-preview-set-params-modal-td { + padding: 1px 5px; +} diff --git a/core/templates/pages/exploration-editor-page/preview-tab/templates/preview-set-parameters-modal.component.html b/core/templates/pages/exploration-editor-page/preview-tab/templates/preview-set-parameters-modal.component.html index a54dd0b75e548..57c7050dcc695 100644 --- a/core/templates/pages/exploration-editor-page/preview-tab/templates/preview-set-parameters-modal.component.html +++ b/core/templates/pages/exploration-editor-page/preview-tab/templates/preview-set-parameters-modal.component.html @@ -19,15 +19,3 @@
- - diff --git a/core/templates/pages/exploration-editor-page/preview-tab/templates/preview-set-parameters-modal.component.ts b/core/templates/pages/exploration-editor-page/preview-tab/templates/preview-set-parameters-modal.component.ts index 718d5d9d748b0..7fc4e7cb681ce 100644 --- a/core/templates/pages/exploration-editor-page/preview-tab/templates/preview-set-parameters-modal.component.ts +++ b/core/templates/pages/exploration-editor-page/preview-tab/templates/preview-set-parameters-modal.component.ts @@ -20,10 +20,12 @@ import {Component, Input} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; +import './preview-set-parameters-modal.component.css'; @Component({ selector: 'oppia-preview-set-parameters-modal', templateUrl: './preview-set-parameters-modal.component.html', + styleUrls: ['./preview-set-parameters-modal.component.css'], }) export class PreviewSetParametersModalComponent extends ConfirmOrCancelModal { // This property is initialized using Angular lifecycle hooks diff --git a/core/templates/pages/exploration-editor-page/services/router.service.ts b/core/templates/pages/exploration-editor-page/services/router.service.ts index 82ef20c2280b5..f8e4bb9ca3503 100644 --- a/core/templates/pages/exploration-editor-page/services/router.service.ts +++ b/core/templates/pages/exploration-editor-page/services/router.service.ts @@ -154,7 +154,7 @@ export class RouterService { forceRefresh: false, }); this._activeTabName = this.TABS.HISTORY.name; - } else if (newPath === this.TABS.FEEDBACK.path) { + } else if (newPath.indexOf(this.TABS.FEEDBACK.path) === 0) { this._activeTabName = this.TABS.FEEDBACK.name; } else if (newPath.indexOf('/gui/') === 0) { this._activeTabName = this.TABS.MAIN.name; diff --git a/core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.css b/core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.css new file mode 100644 index 0000000000000..6e4787e946077 --- /dev/null +++ b/core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.css @@ -0,0 +1,149 @@ +.explore-version-history-button { + font-weight: bold; +} + +.oppia-settings-tab-error-message { + color: #f00; +} + +.mat-form-field { + width: 100%; +} + +.oppia-advanced-features-card .oppia-on-off-switch-label, +.oppia-feedback-card .oppia-on-off-switch-label { + height: 24px; + width: auto; +} + +.oppia-advanced-features-card .oppia-feature-separator { + margin: 20px 0 20px 0; +} + +.oppia-advanced-features-card .form-horizontal > label, +.oppia-feedback-card .form-horizontal > label { + float: left; + height: auto; + padding-right: 0; + width: 610px; +} + +.oppia-settings-container { + margin: 0 auto; + width: 45%; +} + +.oppia-settings-card-container h3 { + margin-top: 0; +} + +.oppia-settings-card-container { + border: 1px solid #707070; + box-shadow: none; + padding: 30px 55px; +} + +.oppia-settings-input-group { + padding: 12px 0; +} + +.oppia-features-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} + +.oppia-roles-container { + position: relative; +} + +.oppia-edit-roles-btn-container { + position: absolute; + right: 0; + text-align: right; +} + +.oppia-edit-roles-btn { + color: #009c8a; + cursor: pointer; + font-weight: bold; +} + +.oppia-permissions-card { + color: #666; +} + +.oppia-delete-button { + background-color: #aa391d; + color: #fff; +} + +.secondary-info-text { + font-size: smaller; +} + +.oppia-basic-settings-header i { + display: none; +} + +.oppia-role-select { + width: 250px; +} + +.oppia-info-icon { + padding-left: 4px; + vertical-align: text-top; +} + +@media screen and (max-width: 1200px) { + .oppia-settings-container { + width: 60%; + } +} + +@media screen and (max-width: 900px) { + .oppia-settings-container { + width: 90%; + } +} + +@media screen and (max-width: 768px) { + .oppia-settings-container { + width: 100%; + } + .oppia-basic-settings-header i { + display: block; + } + .oppia-settings-card-container { + border: 0; + padding: 0; + } + .oppia-basic-settings-header { + display: flex; + justify-content: space-between; + } + .oppia-settings-card-container h3 { + margin-bottom: 0; + } +} + +.oppia-user-list-item { + align-items: center; + border-radius: 10px; + display: flex; + justify-content: space-between; + padding-left: 10px; + width: 50%; +} + +.oppia-user-list-item:hover { + background-color: rgba(189, 189, 189, 0.678); +} + +.oppia-no-voice-artist-message { + text-align: left; +} + +.error-message { + color: #f00; +} diff --git a/core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.html b/core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.html index 84356446e3f13..08962e76f0a16 100644 --- a/core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.html +++ b/core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.html @@ -697,155 +697,3 @@

- - diff --git a/core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.ts b/core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.ts index a1cabf1308610..fbc5cc8140fd0 100644 --- a/core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.ts +++ b/core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.ts @@ -69,10 +69,12 @@ import { VersionHistoryBackendApiService, } from '../services/version-history-backend-api.service'; import {MetadataVersionHistoryModalComponent} from '../modal-templates/metadata-version-history-modal.component'; +import './settings-tab.component.css'; @Component({ selector: 'oppia-settings-tab', templateUrl: './settings-tab.component.html', + styleUrls: ['./settings-tab.component.css'], }) export class SettingsTabComponent implements OnInit, OnDestroy { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/exploration-editor-page/settings-tab/templates/reassign-role-confirmation-modal.component.css b/core/templates/pages/exploration-editor-page/settings-tab/templates/reassign-role-confirmation-modal.component.css new file mode 100644 index 0000000000000..869806c5217cf --- /dev/null +++ b/core/templates/pages/exploration-editor-page/settings-tab/templates/reassign-role-confirmation-modal.component.css @@ -0,0 +1,6 @@ +.oppia-reassign-role-confirmation-modal-header { + margin: 2% auto; +} +.oppia-reassign-role-confirmation-modal-body { + margin: 30px 0; +} diff --git a/core/templates/pages/exploration-editor-page/settings-tab/templates/reassign-role-confirmation-modal.component.html b/core/templates/pages/exploration-editor-page/settings-tab/templates/reassign-role-confirmation-modal.component.html index 83dd3ca029039..f8f469601ff52 100644 --- a/core/templates/pages/exploration-editor-page/settings-tab/templates/reassign-role-confirmation-modal.component.html +++ b/core/templates/pages/exploration-editor-page/settings-tab/templates/reassign-role-confirmation-modal.component.html @@ -10,12 +10,3 @@

Are you sure?

- - diff --git a/core/templates/pages/exploration-editor-page/settings-tab/templates/reassign-role-confirmation-modal.component.ts b/core/templates/pages/exploration-editor-page/settings-tab/templates/reassign-role-confirmation-modal.component.ts index f4866bdfb8187..ac31f4f49b0fb 100644 --- a/core/templates/pages/exploration-editor-page/settings-tab/templates/reassign-role-confirmation-modal.component.ts +++ b/core/templates/pages/exploration-editor-page/settings-tab/templates/reassign-role-confirmation-modal.component.ts @@ -19,10 +19,12 @@ import {Component, Input} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; +import './reassign-role-confirmation-modal.component.css'; @Component({ selector: 'oppia-remove-role-confirmation-modal', templateUrl: './reassign-role-confirmation-modal.component.html', + styleUrls: ['./reassign-role-confirmation-modal.component.css'], }) export class ReassignRoleConfirmationModalComponent extends ConfirmOrCancelModal { @Input() username!: string; diff --git a/core/templates/pages/exploration-editor-page/settings-tab/templates/remove-role-confirmation-modal.component.css b/core/templates/pages/exploration-editor-page/settings-tab/templates/remove-role-confirmation-modal.component.css new file mode 100644 index 0000000000000..51231d0736157 --- /dev/null +++ b/core/templates/pages/exploration-editor-page/settings-tab/templates/remove-role-confirmation-modal.component.css @@ -0,0 +1,6 @@ +.oppia-remove-role-confirmation-modal-header { + margin: 2% auto; +} +.oppia-remove-role-confirmation-modal-body { + margin: 30px 0; +} diff --git a/core/templates/pages/exploration-editor-page/settings-tab/templates/remove-role-confirmation-modal.component.html b/core/templates/pages/exploration-editor-page/settings-tab/templates/remove-role-confirmation-modal.component.html index 57e8510a9f4e3..dab54fd3de098 100644 --- a/core/templates/pages/exploration-editor-page/settings-tab/templates/remove-role-confirmation-modal.component.html +++ b/core/templates/pages/exploration-editor-page/settings-tab/templates/remove-role-confirmation-modal.component.html @@ -10,12 +10,3 @@

Ar - - diff --git a/core/templates/pages/exploration-editor-page/settings-tab/templates/remove-role-confirmation-modal.component.ts b/core/templates/pages/exploration-editor-page/settings-tab/templates/remove-role-confirmation-modal.component.ts index 67160ccde6253..35c2f83251015 100644 --- a/core/templates/pages/exploration-editor-page/settings-tab/templates/remove-role-confirmation-modal.component.ts +++ b/core/templates/pages/exploration-editor-page/settings-tab/templates/remove-role-confirmation-modal.component.ts @@ -19,10 +19,12 @@ import {Component, Input} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; +import './remove-role-confirmation-modal.component.css'; @Component({ selector: 'oppia-remove-role-confirmation-modal', templateUrl: './remove-role-confirmation-modal.component.html', + styleUrls: ['./remove-role-confirmation-modal.component.css'], }) export class RemoveRoleConfirmationModalComponent extends ConfirmOrCancelModal { @Input() username!: string; diff --git a/core/templates/pages/exploration-editor-page/statistics-tab/templates/state-stats-modal.component.css b/core/templates/pages/exploration-editor-page/statistics-tab/templates/state-stats-modal.component.css index f5a59eadbef98..56d4c23d67fc0 100644 --- a/core/templates/pages/exploration-editor-page/statistics-tab/templates/state-stats-modal.component.css +++ b/core/templates/pages/exploration-editor-page/statistics-tab/templates/state-stats-modal.component.css @@ -1,9 +1,6 @@ -.oppia-state-stats-modal-body .section { - margin-top: 18px; +.oppia-state-stats-modal-body .oppia-quit-card-text { + padding-left: 170px; } -.oppia-state-stats-warning { - background: #f9edbe; - border-radius: 2px; - padding: 12px; - width: 100%; +.oppia-state-stats-modal-body .oppia-pie-chart { + padding-left: 76px; } diff --git a/core/templates/pages/exploration-editor-page/statistics-tab/templates/state-stats-modal.component.html b/core/templates/pages/exploration-editor-page/statistics-tab/templates/state-stats-modal.component.html index c3b1cd481245f..a0d7747b3125e 100644 --- a/core/templates/pages/exploration-editor-page/statistics-tab/templates/state-stats-modal.component.html +++ b/core/templates/pages/exploration-editor-page/statistics-tab/templates/state-stats-modal.component.html @@ -56,12 +56,3 @@

Statistics for "{{ stateName }}" - - diff --git a/core/templates/pages/exploration-editor-page/statistics-tab/templates/state-stats-modal.component.ts b/core/templates/pages/exploration-editor-page/statistics-tab/templates/state-stats-modal.component.ts index 0e1ab4c6d7164..549ac1394ef5c 100644 --- a/core/templates/pages/exploration-editor-page/statistics-tab/templates/state-stats-modal.component.ts +++ b/core/templates/pages/exploration-editor-page/statistics-tab/templates/state-stats-modal.component.ts @@ -40,6 +40,7 @@ interface PieChartOpitons { @Component({ selector: 'oppia-state-stats-modal', templateUrl: './state-stats-modal.component.html', + styleUrls: ['./state-stats-modal.component.css'], }) export class StateStatsModalComponent extends ConfirmOrCancelModal diff --git a/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/add-audio-translation-modal.component.css b/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/add-audio-translation-modal.component.css new file mode 100644 index 0000000000000..32c73890124d4 --- /dev/null +++ b/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/add-audio-translation-modal.component.css @@ -0,0 +1,11 @@ +.oppia-audio-file-upload-field-error-message, +.oppia-updated-audio-file-upload-message { + color: red; + display: inline-block; + font-size: 14px; + padding: 4px; +} + +.oppia-audio-file-upload-field-error-message i { + margin-right: 4px; +} diff --git a/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/add-audio-translation-modal.component.html b/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/add-audio-translation-modal.component.html index 733670ee2daac..35fa4ed59b6d1 100644 --- a/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/add-audio-translation-modal.component.html +++ b/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/add-audio-translation-modal.component.html @@ -39,17 +39,3 @@

Add Voiceover

{{ saveButtonText }} - - diff --git a/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/add-audio-translation-modal.component.ts b/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/add-audio-translation-modal.component.ts index eebbf4ced2ecc..436633c73aa78 100644 --- a/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/add-audio-translation-modal.component.ts +++ b/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/add-audio-translation-modal.component.ts @@ -21,10 +21,12 @@ import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; import {AssetsBackendApiService} from 'services/assets-backend-api.service'; import {PageContextService} from 'services/page-context.service'; +import './add-audio-translation-modal.component.css'; @Component({ selector: 'oppia-add-audio-translation-modal', templateUrl: './add-audio-translation-modal.component.html', + styleUrls: ['./add-audio-translation-modal.component.css'], }) export class AddAudioTranslationModalComponent extends ConfirmOrCancelModal diff --git a/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/welcome-translation-modal.component.css b/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/welcome-translation-modal.component.css new file mode 100644 index 0000000000000..c833ed5d8c24a --- /dev/null +++ b/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/welcome-translation-modal.component.css @@ -0,0 +1,60 @@ +.oppia-welcome-modal .modal-body { + font-family: "Capriola", "Roboto", Arial, sans-serif; + height: 100%; + margin: 0 auto; + top: 10%; + width: 95%; +} + +.oppia-welcome-modal .oppia-welcome-modal-h1 { + color: #015c53; +} + +.oppia-welcome-modal .oppia-welcome-modal-p { + font-size: 0.9em; + margin-bottom: 6px; + margin-top: 0; +} + +.oppia-welcome-modal .oppia-welcome-modal-button { + background-color: #015c53; + border-radius: 4px; + color: #fff; + font-family: "Roboto", Arial, sans-serif; + font-size: 14px; + margin-top: 10px; + text-transform: uppercase; + width: 250px; +} +.oppia-welcome-modal .oppia-welcome-modal-button:hover, +.oppia-welcome-modal .oppia-welcome-modal-button:focus, +.oppia-welcome-modal .oppia-welcome-modal-button:active { + background-color: rgba(5, 190, 178, 1); + color: #fff; +} + +.oppia-welcome-modal .oppia-welcome-modal-img { + min-width: 100px; +} + +.oppia-welcome-modal .modal-content { + border-radius: 0; + height: 100%; + min-height: 450px; +} + +.oppia-welcome-modal .modal-dialog { + height: 70%; + width: 70%; +} + +@media (max-width: 770px) { + .oppia-welcome-modal .modal-body { + top: 5%; + } + + .oppia-welcome-modal .modal-dialog { + margin: 10% auto; + width: 95%; + } +} diff --git a/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/welcome-translation-modal.component.html b/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/welcome-translation-modal.component.html index 9a08d68c1e6ec..8cbd7998e08a6 100644 --- a/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/welcome-translation-modal.component.html +++ b/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/welcome-translation-modal.component.html @@ -27,66 +27,3 @@

Welcome!

- - diff --git a/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/welcome-translation-modal.component.ts b/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/welcome-translation-modal.component.ts index 8d98772edfd89..6b48fa662efbb 100644 --- a/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/welcome-translation-modal.component.ts +++ b/core/templates/pages/exploration-editor-page/translation-tab/modal-templates/welcome-translation-modal.component.ts @@ -22,10 +22,12 @@ import {UrlInterpolationService} from 'domain/utilities/url-interpolation.servic import {PageContextService} from 'services/page-context.service'; import {SiteAnalyticsService} from 'services/site-analytics.service'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; +import './welcome-translation-modal.component.css'; @Component({ selector: 'oppia-welcome-translation-modal', templateUrl: './welcome-translation-modal.component.html', + styleUrls: ['./welcome-translation-modal.component.css'], }) export class WelcomeTranslationModalComponent extends ConfirmOrCancelModal diff --git a/core/templates/pages/story-editor-page/editor-tab/story-editor.component.html b/core/templates/pages/story-editor-page/editor-tab/story-editor.component.html index 374400833374e..368f97c35ee78 100644 --- a/core/templates/pages/story-editor-page/editor-tab/story-editor.component.html +++ b/core/templates/pages/story-editor-page/editor-tab/story-editor.component.html @@ -1378,5 +1378,42 @@

Story Card

.inline-arc-boundary-row { margin: 8px 0; } .inline-split-line-row { margin: 4px 0; } .inline-arc-boundary-row .arc-boundary-header { align-items: flex-start; display: flex; justify-content: space-between; } + @media screen and (max-width: 768px) { + .inline-arc-boundary-row .arc-boundary-header { + flex-direction: column; + gap: 12px; + } + .arc-boundary-info { + min-width: 0; + width: 100%; + } + .arc-boundary-description, + .arc-boundary-chapter-count { + margin-left: 44px; + } + .arc-boundary-actions { + flex-wrap: wrap; + margin-left: 44px; + } + .arc-edit-button, + .arc-remove-button { + text-align: left; + white-space: normal; + } + } + @media screen and (max-width: 540px) { + .oppia-edit-arc-modal .modal-dialog { + margin: .5rem; + max-width: calc(100vw - 1rem); + width: auto; + } + .oppia-edit-arc-modal .modal-content { + min-width: 0; + width: 100%; + } + .oppia-edit-arc-modal .modal-footer { + flex-wrap: wrap; + } + } diff --git a/core/templates/pages/story-editor-page/editor-tab/story-editor.component.spec.ts b/core/templates/pages/story-editor-page/editor-tab/story-editor.component.spec.ts index 9d5ea3c4ac4d7..ee5443f663e66 100644 --- a/core/templates/pages/story-editor-page/editor-tab/story-editor.component.spec.ts +++ b/core/templates/pages/story-editor-page/editor-tab/story-editor.component.spec.ts @@ -208,6 +208,9 @@ describe('Story Editor Component having three story nodes', () => { 'fractions' ); spyOn(storyEditorStateService, 'getTopicName').and.returnValue('addition'); + mockPlatformFeatureService.status.StoryEditorArcs = { + isEnabled: false, + }; component.ngOnInit(); }); @@ -861,6 +864,7 @@ describe('Story Editor Component having three story nodes', () => { expect(modalSpy).toHaveBeenCalledWith(EditArcModalComponent, { backdrop: 'static', + windowClass: 'oppia-edit-arc-modal', }); expect(updateArcPropertySpy).toHaveBeenCalledTimes(2); })); @@ -1019,6 +1023,51 @@ describe('Story Editor Component having three story nodes', () => { expect(component.isStoryEditorArcsFeatureFlagEnabled()).toBe(true); }); + it('should backfill a default arc when arc data is missing', () => { + mockPlatformFeatureService.status.StoryEditorArcs = { + isEnabled: true, + }; + + component.storyContents = story.getStoryContents(); + expect(component.storyContents.getArcs().length).toBe(0); + + component._initEditor(); + + expect(component.storyContents.getArcs().length).toBe(1); + expect(component.storyContents.getArcs()[0].getTitle()).toBe( + 'All Chapters' + ); + expect(component.storyContents.getArcs()[0].getNodeIds()).toEqual([ + 'node_1', + 'node_2', + 'node_3', + ]); + }); + + it('should normalize stale arc node ids and include missing nodes', () => { + mockPlatformFeatureService.status.StoryEditorArcs = { + isEnabled: true, + }; + + component.storyContents = story.getStoryContents(); + component.storyContents.addArc( + ArcModel.createNew('arc_1', 'Arc 1', '', ['node_2', 'ghost_node']) + ); + component.storyContents.addArc( + ArcModel.createNew('arc_2', 'Arc 2', '', ['node_3']) + ); + + component._initEditor(); + + expect(component.storyContents.getArcs()[0].getNodeIds()).toEqual([ + 'node_2', + 'node_1', + ]); + expect(component.storyContents.getArcs()[1].getNodeIds()).toEqual([ + 'node_3', + ]); + }); + it('should return true when node index is zero for isSameArc', () => { expect(component.isSameArc(0)).toBe(true); }); diff --git a/core/templates/pages/story-editor-page/editor-tab/story-editor.component.ts b/core/templates/pages/story-editor-page/editor-tab/story-editor.component.ts index affffb9fbfe3e..0e46339461596 100644 --- a/core/templates/pages/story-editor-page/editor-tab/story-editor.component.ts +++ b/core/templates/pages/story-editor-page/editor-tab/story-editor.component.ts @@ -173,6 +173,50 @@ export class StoryEditorComponent implements OnInit, OnDestroy { this.rearrangeNodeInList(event.previousIndex, event.currentIndex); } + private ensureArcMembershipForNodes(): void { + if ( + !this.isStoryEditorArcsFeatureFlagEnabled() || + !this.storyContents || + this.storyContents.getNodes().length === 0 + ) { + return; + } + + const nodeIds = this.storyContents.getNodes().map(node => node.getId()); + const arcs = this.storyContents.getArcs(); + + // Backfill a default arc for stories that predate arc data. + if (arcs.length === 0) { + this.storyContents.addArc( + ArcModel.createNew( + 'arc_' + Date.now().toString(), + 'All Chapters', + '', + nodeIds + ) + ); + return; + } + + const validNodeIdSet = new Set(nodeIds); + const coveredNodeIds = new Set(); + + arcs.forEach(arc => { + // Remove stale node references that are no longer in story contents. + const normalizedNodeIds = arc + .getNodeIds() + .filter(id => validNodeIdSet.has(id)); + arc.setNodeIds(normalizedNodeIds); + normalizedNodeIds.forEach(id => coveredNodeIds.add(id)); + }); + + const missingNodeIds = nodeIds.filter(id => !coveredNodeIds.has(id)); + if (missingNodeIds.length > 0) { + const firstArcNodeIds = arcs[0].getNodeIds(); + arcs[0].setNodeIds([...firstArcNodeIds, ...missingNodeIds]); + } + } + moveNodeUpInStory(index: number): void { this.toggleChapterEditOptions(-1); this.rearrangeNodeInList(index, index - 1); @@ -297,6 +341,7 @@ export class StoryEditorComponent implements OnInit, OnDestroy { const arc = this.storyContents.getArcs()[arcIndex]; const modalRef = this.ngbModal.open(EditArcModalComponent, { backdrop: 'static', + windowClass: 'oppia-edit-arc-modal', }); modalRef.componentInstance.arcTitle = arc.getTitle(); modalRef.componentInstance.arcDescription = arc.getDescription(); @@ -372,6 +417,7 @@ export class StoryEditorComponent implements OnInit, OnDestroy { this.story = this.storyEditorStateService.getStory(); if (this.story) { this.storyContents = this.story.getStoryContents(); + this.ensureArcMembershipForNodes(); this.disconnectedNodes = []; this.linearNodesList = []; this.nodes = []; diff --git a/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component.html b/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component.html index 97dd39d8f0e72..24ed26ac8f905 100644 --- a/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component.html +++ b/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component.html @@ -29,6 +29,7 @@

diff --git a/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component.spec.ts b/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component.spec.ts index f69425bebb97c..e27a524c37f8d 100644 --- a/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component.spec.ts +++ b/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component.spec.ts @@ -42,7 +42,10 @@ import {AppConstants} from 'app.constants'; import {BehaviorSubject} from 'rxjs'; import {AlertsService} from 'services/alerts.service'; import {WindowRef} from 'services/contextual/window-ref.service'; -import {ReportType} from '../../domain/feedback/feedback.model'; +import { + CreatorFeedbackType, + ReportType, +} from '../../domain/feedback/feedback.model'; describe('TechnicalFeedbackDashboardPageComponent', () => { let component: TechnicalFeedbackDashboardPageComponent; @@ -123,6 +126,7 @@ describe('TechnicalFeedbackDashboardPageComponent', () => { searchText: '', status: FeedbackStatus.OPEN, technicalTeam: TechnicalTeamType.TECH_EXTERNAL, + creatorFeedbackType: CreatorFeedbackType.FEEDBACK, dateRange: { start: null, end: null, diff --git a/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component.ts b/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component.ts index c9063ad2dab13..02718e1ab2c02 100644 --- a/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component.ts +++ b/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component.ts @@ -23,6 +23,7 @@ import {AssetsBackendApiService} from 'services/assets-backend-api.service'; import {AlertsService} from 'services/alerts.service'; import {WindowRef} from 'services/contextual/window-ref.service'; import { + CreatorFeedbackType, FeedbackCardConfig, FeedbackFilterConfig, FeedbackFilterState, @@ -53,6 +54,7 @@ export class TechnicalFeedbackDashboardPageComponent { readonly filterConfig: FeedbackFilterConfig = TECHNICAL_DASHBOARD_FILTER_CONFIG; readonly cardConfig: FeedbackCardConfig = TECHNICAL_DASHBOARD_CARD_CONFIG; + readonly statusOptions = this.filterConfig.statusOptions; currentPage: number = 1; selectedTeam: TechnicalTeamType | null = null; @@ -69,6 +71,7 @@ export class TechnicalFeedbackDashboardPageComponent { searchText: '', status: FeedbackStatus.OPEN, technicalTeam: TechnicalTeamType.TECH_EXTERNAL, + creatorFeedbackType: CreatorFeedbackType.FEEDBACK, dateRange: { start: null, end: null, @@ -97,6 +100,7 @@ export class TechnicalFeedbackDashboardPageComponent { private loadScreenshot(): void { const response = this.feedbackDetailResponse; + this.screenshotDataUrl = null; if (!response?.screenshot_entity_id || !response?.screenshot_filename) { return; } diff --git a/core/templates/pages/topic-viewer-page/deprecations/modals/practice-session-confirmation-modal.component.ts b/core/templates/pages/topic-viewer-page/deprecations/modals/practice-session-confirmation-modal.component.ts index d28b7e21bba40..50e8b4332f1e9 100644 --- a/core/templates/pages/topic-viewer-page/deprecations/modals/practice-session-confirmation-modal.component.ts +++ b/core/templates/pages/topic-viewer-page/deprecations/modals/practice-session-confirmation-modal.component.ts @@ -16,8 +16,9 @@ * @fileoverview Component for practice session confirmation modal. */ -import {Component} from '@angular/core'; +import {Component, Optional} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {MatBottomSheetRef} from '@angular/material/bottom-sheet'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; @Component({ @@ -26,7 +27,11 @@ import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-e styleUrls: ['./practice-session-confirmation-modal.component.css'], }) export class PracticeSessionConfirmationModal extends ConfirmOrCancelModal { - constructor(private ngbActiveModal: NgbActiveModal) { - super(ngbActiveModal); + constructor( + @Optional() private ngbActiveModal: NgbActiveModal, + @Optional() + private practiceSessionConfirmationBottomSheetRef?: MatBottomSheetRef + ) { + super(ngbActiveModal, practiceSessionConfirmationBottomSheetRef); } } diff --git a/core/templates/pages/volunteer-page/volunteer-page.component.css b/core/templates/pages/volunteer-page/volunteer-page.component.css index a890c72f684c0..5c86ec11cef8d 100644 --- a/core/templates/pages/volunteer-page/volunteer-page.component.css +++ b/core/templates/pages/volunteer-page/volunteer-page.component.css @@ -11,6 +11,8 @@ margin: 0 auto; min-height: 44px; min-width: 240px; + padding-left: 2.4rem; + padding-right: 2.4rem; } .volunteer-page .oppia-volunteer-banner-content .volunteer-btn { diff --git a/core/templates/pages/volunteer-page/volunteer-page.component.html b/core/templates/pages/volunteer-page/volunteer-page.component.html index 76042139310a7..15ffa2bd0206b 100644 --- a/core/templates/pages/volunteer-page/volunteer-page.component.html +++ b/core/templates/pages/volunteer-page/volunteer-page.component.html @@ -8,9 +8,9 @@

{{ 'I18N_VOLUNTE @@ -511,9 +511,9 @@

{{ 'I18N_VOLUNTE

{{ 'I18N_VOLUNTEER_PAGE_FOOTER' | translate }}

diff --git a/core/templates/pages/volunteer-page/volunteer-page.component.ts b/core/templates/pages/volunteer-page/volunteer-page.component.ts index 1d9b1029ce460..66e98fa1245f8 100644 --- a/core/templates/pages/volunteer-page/volunteer-page.component.ts +++ b/core/templates/pages/volunteer-page/volunteer-page.component.ts @@ -42,7 +42,7 @@ export class VolunteerPageComponent implements OnInit, OnDestroy { directiveSubscriptions = new Subscription(); bannerImgPath = ''; footerImgPath = ''; - formLink = AppConstants.VOLUNTEER_FORM_LINK; + volunteerIdealistLink = AppConstants.VOLUNTEER_IDEALIST_LINK; art!: { images: string[]; caption: { diff --git a/core/templates/services/rte-helper-modal.component.spec.ts b/core/templates/services/rte-helper-modal.component.spec.ts index 2c6949823bef8..56c220eaecaf6 100644 --- a/core/templates/services/rte-helper-modal.component.spec.ts +++ b/core/templates/services/rte-helper-modal.component.spec.ts @@ -43,6 +43,11 @@ import { TranslateModule, TranslateService, } from '@ngx-translate/core'; +import { + MatBottomSheetRef, + MAT_BOTTOM_SHEET_DATA, +} from '@angular/material/bottom-sheet'; +import {Subject} from 'rxjs'; describe('RteHelperModalComponent', () => { let component: RteHelperModalComponent; @@ -1061,3 +1066,341 @@ describe('RteHelperModalComponent', () => { })); }); }); + +const rteSaveEmitter = new EventEmitter(); +describe('RteHelperModalComponent in bottom sheet mode', () => { + let component: RteHelperModalComponent; + let fixture: ComponentFixture; + let bottomSheetRef: jasmine.SpyObj; + let keydownSubject: Subject; + let pageContextService: PageContextService; + let assetsBackendApiService: AssetsBackendApiService; + let imageUploadHelperService: ImageUploadHelperService; + let alertsService: AlertsService; + + const modalData = { + componentId: 'Math', + customizationArgSpecs: [], + attrsCustomizationArgsDict: {}, + componentIsNewlyCreated: true, + }; + + beforeEach(waitForAsync(() => { + keydownSubject = new Subject(); + bottomSheetRef = jasmine.createSpyObj('MatBottomSheetRef', [ + 'dismiss', + 'keydownEvents', + ]); + bottomSheetRef.keydownEvents.and.returnValue(keydownSubject.asObservable()); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + SharedFormsModule, + FormsModule, + ReactiveFormsModule, + DirectivesModule, + NgbModalModule, + HttpClientTestingModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateFakeLoader, + }, + }), + ], + declarations: [RteHelperModalComponent], + providers: [ + AlertsService, + PageContextService, + ImageLocalStorageService, + AssetsBackendApiService, + ImageUploadHelperService, + { + provide: NgbActiveModal, + useValue: jasmine.createSpyObj('activeModal', ['close', 'dismiss']), + }, + { + provide: ExternalRteSaveService, + useValue: {onExternalRteSave: rteSaveEmitter}, + }, + TranslateService, + {provide: MatBottomSheetRef, useValue: bottomSheetRef}, + {provide: MAT_BOTTOM_SHEET_DATA, useValue: modalData}, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(RteHelperModalComponent); + component = fixture.componentInstance; + pageContextService = TestBed.inject(PageContextService); + assetsBackendApiService = TestBed.inject(AssetsBackendApiService); + imageUploadHelperService = TestBed.inject(ImageUploadHelperService); + alertsService = TestBed.inject(AlertsService); + fixture.detectChanges(); + }); + + it('should set properties from the injected bottom sheet data', () => { + expect(component.componentId).toEqual('Math'); + expect(component.componentIsNewlyCreated).toBe(true); + }); + + it('should dismiss the bottom sheet when Escape key is pressed', () => { + keydownSubject.next(new KeyboardEvent('keydown', {key: 'Escape'})); + expect(bottomSheetRef.dismiss).toHaveBeenCalled(); + }); + + it('should not dismiss the bottom sheet when a non-Escape key is pressed', () => { + keydownSubject.next(new KeyboardEvent('keydown', {key: 'Enter'})); + expect(bottomSheetRef.dismiss).not.toHaveBeenCalled(); + }); + + it('should dismiss the bottom sheet with true when newly created and cancelled', () => { + component.cancel(); + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith(true); + }); + + it('should dismiss the bottom sheet with true when deleted', () => { + component.delete(); + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith(true); + }); + + it('should dismiss the bottom sheet with false when cancelled and not newly created', fakeAsync(() => { + component.componentId = 'link'; + component.attrsCustomizationArgsDict = {alt: '', caption: '', filepath: ''}; + component.customizationArgSpecs = [ + {name: 'filepath', default_value: ''}, + {name: 'caption', default_value: ''}, + {name: 'alt', default_value: ''}, + ]; + component.ngOnInit(); + flush(); + component.componentIsNewlyCreated = false; + component.cancel(); + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith(false); + })); + + it('should dismiss the bottom sheet with the customization args on save', fakeAsync(() => { + component.componentId = 'link'; + component.attrsCustomizationArgsDict = {alt: '', caption: '', filepath: ''}; + component.customizationArgSpecs = [ + {name: 'filepath', default_value: ''}, + {name: 'caption', default_value: ''}, + {name: 'alt', default_value: ''}, + ]; + component.ngOnInit(); + flush(); + component.save(); + expect(bottomSheetRef.dismiss).toHaveBeenCalled(); + })); + + it('should dismiss the bottom sheet with math customization args on save', fakeAsync(() => { + component.componentId = 'math'; + component.attrsCustomizationArgsDict = { + math_content: {raw_latex: '', svg_filename: ''}, + }; + component.customizationArgSpecs = [ + {name: 'math_content', default_value: {raw_latex: '', svg_filename: ''}}, + ]; + spyOn(pageContextService, 'getImageSaveDestination').and.returnValue( + AppConstants.IMAGE_SAVE_DESTINATION_SERVER + ); + spyOn(pageContextService, 'getEntityType').and.returnValue('exploration'); + component.tmpCustomizationArgs = []; + (component as unknown as {data: undefined}).data = undefined; + component.ngOnInit(); + flush(); + component.customizationArgsForm.value[0] = { + raw_latex: 'x^2', + svgFile: 'Svg Data', + svg_filename: 'mathImage.svg', + mathExpressionSvgIsBeingProcessed: false, + }; + component.onCustomizationArgsFormChange( + component.customizationArgsForm.value + ); + spyOn(assetsBackendApiService, 'saveMathExpressionImage').and.returnValue( + Promise.resolve({filename: 'mathImage.svg'}) + ); + spyOn( + imageUploadHelperService, + 'convertImageDataToImageFile' + ).and.returnValue(new Blob()); + component.save(); + flush(); + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith({ + math_content: {raw_latex: 'x^2', svg_filename: 'mathImage.svg'}, + }); + })); + + it('should dismiss on server error while saving math', fakeAsync(() => { + component.componentId = 'math'; + component.attrsCustomizationArgsDict = { + math_content: {raw_latex: '', svg_filename: ''}, + }; + component.customizationArgSpecs = [ + {name: 'math_content', default_value: {raw_latex: '', svg_filename: ''}}, + ]; + spyOn(alertsService, 'addWarning'); + spyOn(pageContextService, 'getImageSaveDestination').and.returnValue( + AppConstants.IMAGE_SAVE_DESTINATION_SERVER + ); + spyOn(pageContextService, 'getEntityType').and.returnValue('exploration'); + component.tmpCustomizationArgs = []; + (component as unknown as {data: undefined}).data = undefined; + component.ngOnInit(); + flush(); + component.customizationArgsForm.value[0] = { + raw_latex: 'x^2', + svgFile: 'Svg Data', + svg_filename: 'mathImage.svg', + mathExpressionSvgIsBeingProcessed: false, + }; + spyOn(assetsBackendApiService, 'saveMathExpressionImage').and.returnValue( + Promise.reject({error: 'Error communicating with server.'}) + ); + spyOn( + imageUploadHelperService, + 'convertImageDataToImageFile' + ).and.returnValue(new Blob()); + component.save(); + flush(); + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith('cancel'); + })); + + it('should dismiss the bottom sheet when math SVG exceeds 100 KB', fakeAsync(() => { + component.componentId = 'math'; + component.attrsCustomizationArgsDict = { + math_content: {raw_latex: '', svg_filename: ''}, + }; + component.customizationArgSpecs = [ + {name: 'math_content', default_value: {raw_latex: '', svg_filename: ''}}, + ]; + spyOn(pageContextService, 'getEntityType').and.returnValue('exploration'); + component.tmpCustomizationArgs = []; + (component as unknown as {data: undefined}).data = undefined; + component.ngOnInit(); + flush(); + component.customizationArgsForm.value[0] = { + raw_latex: 'x^2 + y^2 + x^2 + y^2 + x^2 + y^2 + x^2 + y^2 + x^2', + svgFile: 'Svg Data', + svg_filename: 'mathImage.svg', + }; + component.onCustomizationArgsFormChange( + component.customizationArgsForm.value + ); + spyOn( + imageUploadHelperService, + 'convertImageDataToImageFile' + ).and.returnValue( + new Blob([new ArrayBuffer(102 * 1024)], { + type: 'application/octet-stream', + }) + ); + component.save(); + flush(); + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith('cancel'); + })); + + it('should dismiss the bottom sheet when SVG exceeds 1 MB for blog post', fakeAsync(() => { + component.componentId = 'math'; + component.attrsCustomizationArgsDict = { + math_content: {raw_latex: '', svg_filename: ''}, + }; + component.customizationArgSpecs = [ + {name: 'math_content', default_value: {raw_latex: '', svg_filename: ''}}, + ]; + spyOn(pageContextService, 'getEntityType').and.returnValue( + AppConstants.ENTITY_TYPE.BLOG_POST + ); + component.tmpCustomizationArgs = []; + (component as unknown as {data: undefined}).data = undefined; + component.ngOnInit(); + flush(); + component.customizationArgsForm.value[0] = { + raw_latex: 'x^2 + y^2 + x^2 + y^2 + x^2 + y^2 + x^2 + y^2 + x^2', + svgFile: 'Svg Data', + svg_filename: 'mathImage.svg', + }; + component.onCustomizationArgsFormChange( + component.customizationArgsForm.value + ); + spyOn( + imageUploadHelperService, + 'convertImageDataToImageFile' + ).and.returnValue( + new Blob([new ArrayBuffer(102 * 1024 * 1024)], { + type: 'application/octet-stream', + }) + ); + component.save(); + flush(); + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith('cancel'); + })); + + it('should dismiss the bottom sheet while saving math in local storage', fakeAsync(() => { + component.componentId = 'math'; + component.attrsCustomizationArgsDict = { + math_content: {raw_latex: '', svg_filename: ''}, + }; + component.customizationArgSpecs = [ + {name: 'math_content', default_value: {raw_latex: '', svg_filename: ''}}, + ]; + spyOn(pageContextService, 'getEntityType').and.returnValue('exploration'); + spyOn(pageContextService, 'getImageSaveDestination').and.returnValue( + AppConstants.IMAGE_SAVE_DESTINATION_LOCAL_STORAGE + ); + component.tmpCustomizationArgs = []; + (component as unknown as {data: undefined}).data = undefined; + component.ngOnInit(); + flush(); + component.customizationArgsForm.value[0] = { + raw_latex: 'x^2', + svgFile: 'Svg Data', + svg_filename: 'mathImage.svg', + mathExpressionSvgIsBeingProcessed: false, + }; + component.onCustomizationArgsFormChange( + component.customizationArgsForm.value + ); + spyOn( + imageUploadHelperService, + 'convertImageDataToImageFile' + ).and.returnValue(new Blob()); + component.save(); + flush(); + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith({ + math_content: {raw_latex: 'x^2', svg_filename: 'mathImage.svg'}, + }); + })); + + it('should dismiss the bottom sheet with cancel when rawLatex or filename is empty', fakeAsync(() => { + component.componentId = 'math'; + component.attrsCustomizationArgsDict = { + math_content: {raw_latex: '', svg_filename: ''}, + }; + component.customizationArgSpecs = [ + { + name: 'math_content', + default_value: {raw_latex: '', svg_filename: ''}, + }, + ]; + spyOn(pageContextService, 'getEntityType').and.returnValue('exploration'); + component.tmpCustomizationArgs = []; + (component as unknown as {data: undefined}).data = undefined; + component.ngOnInit(); + flush(); + component.customizationArgsForm.value[0] = { + raw_latex: '', + svgFile: null, + svg_filename: '', + }; + component.onCustomizationArgsFormChange( + component.customizationArgsForm.value + ); + component.save(); + flush(); + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith('cancel'); + })); +}); diff --git a/core/templates/services/rte-helper-modal.component.ts b/core/templates/services/rte-helper-modal.component.ts index 091376537d5e0..afc565e91c654 100644 --- a/core/templates/services/rte-helper-modal.component.ts +++ b/core/templates/services/rte-helper-modal.component.ts @@ -16,9 +16,13 @@ * @fileoverview Component for RteHelperModal. */ -import {Component, Input, ViewChild} from '@angular/core'; +import {Component, Input, ViewChild, Optional, Inject} from '@angular/core'; import {NgForm} from '@angular/forms'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import { + MatBottomSheetRef, + MAT_BOTTOM_SHEET_DATA, +} from '@angular/material/bottom-sheet'; import {AppConstants} from 'app.constants'; import cloneDeep from 'lodash/cloneDeep'; import {AlertsService} from 'services/alerts.service'; @@ -103,6 +107,13 @@ export type RteComponentId = { [K in keyof ComponentSpecsType]: ComponentSpecsType[K]['frontend_id']; }[keyof ComponentSpecsType]; +interface RteHelperModalData { + componentId: RteComponentId; + customizationArgSpecs: CustomizationArgsSpecsType; + attrsCustomizationArgsDict: CustomizationArgsForRteType; + componentIsNewlyCreated: boolean; +} + @Component({ selector: 'oppia-rte-helper-modal', templateUrl: './rte-helper-modal.component.html', @@ -142,7 +153,6 @@ export class RteHelperModalComponent { }; constructor( - private ngbActiveModal: NgbActiveModal, private externalRteSaveService: ExternalRteSaveService, private alertsService: AlertsService, private fb: FormBuilder, @@ -150,10 +160,29 @@ export class RteHelperModalComponent { private pageContextService: PageContextService, private imageLocalStorageService: ImageLocalStorageService, private imageUploadHelperService: ImageUploadHelperService, - private htmlLengthService: HtmlLengthService + private htmlLengthService: HtmlLengthService, + @Optional() private ngbActiveModal: NgbActiveModal, + @Optional() + private rteHelperBottomSheetRef?: MatBottomSheetRef, + @Optional() + @Inject(MAT_BOTTOM_SHEET_DATA) + private data?: RteHelperModalData ) {} ngOnInit(): void { + if (this.data) { + this.componentId = this.data.componentId; + this.customizationArgSpecs = this.data.customizationArgSpecs; + this.attrsCustomizationArgsDict = this.data.attrsCustomizationArgsDict; + this.componentIsNewlyCreated = this.data.componentIsNewlyCreated; + } + if (this.rteHelperBottomSheetRef) { + this.rteHelperBottomSheetRef.keydownEvents().subscribe(event => { + if (event.key === 'Escape') { + this.rteHelperBottomSheetRef?.dismiss(); + } + }); + } for (let i = 0; i < this.customizationArgSpecs.length; i++) { const caName = this.customizationArgSpecs[i].name; if (caName === 'math_content') { @@ -230,16 +259,21 @@ export class RteHelperModalComponent { } cancel(): void { - if (this.componentIsNewlyCreated) { - this.ngbActiveModal.dismiss(true); + const dismissValue = this.componentIsNewlyCreated ? true : false; + if (this.rteHelperBottomSheetRef) { + this.rteHelperBottomSheetRef.dismiss(dismissValue); } else { - this.ngbActiveModal.dismiss(false); + this.ngbActiveModal.dismiss(dismissValue); } this.customizationArgsFormSubscription.unsubscribe(); } delete(): void { - this.ngbActiveModal.dismiss(true); + if (this.rteHelperBottomSheetRef) { + this.rteHelperBottomSheetRef.dismiss(true); + } else { + this.ngbActiveModal.dismiss(true); + } this.customizationArgsFormSubscription.unsubscribe(); } @@ -504,7 +538,11 @@ export class RteHelperModalComponent { 'The rawLatex or svgFileName for a Math expression should not ' + 'be empty.' ); - this.ngbActiveModal.dismiss('cancel'); + if (this.rteHelperBottomSheetRef) { + this.rteHelperBottomSheetRef.dismiss('cancel'); + } else { + this.ngbActiveModal.dismiss('cancel'); + } return; } const resampledFile = @@ -529,7 +567,11 @@ export class RteHelperModalComponent { "and '+ z^2'", 5000 ); - this.ngbActiveModal.dismiss('cancel'); + if (this.rteHelperBottomSheetRef) { + this.rteHelperBottomSheetRef.dismiss('cancel'); + } else { + this.ngbActiveModal.dismiss('cancel'); + } return; } if ( @@ -543,7 +585,11 @@ export class RteHelperModalComponent { }; const caName = tmpCustomizationArgs[0].name; customizationArgsDict[caName] = mathContentDict; - this.ngbActiveModal.close(customizationArgsDict); + if (this.rteHelperBottomSheetRef) { + this.rteHelperBottomSheetRef.dismiss(customizationArgsDict); + } else { + this.ngbActiveModal.close(customizationArgsDict); + } return; } this.assetsBackendApiService @@ -561,13 +607,21 @@ export class RteHelperModalComponent { }; const caName = tmpCustomizationArgs[0].name; customizationArgsDict[caName] = mathContentDict; - this.ngbActiveModal.close(customizationArgsDict); + if (this.rteHelperBottomSheetRef) { + this.rteHelperBottomSheetRef.dismiss(customizationArgsDict); + } else { + this.ngbActiveModal.close(customizationArgsDict); + } }, errorResponse => { this.alertsService.addWarning( errorResponse.error || 'Error communicating with server.' ); - this.ngbActiveModal.dismiss('cancel'); + if (this.rteHelperBottomSheetRef) { + this.rteHelperBottomSheetRef.dismiss('cancel'); + } else { + this.ngbActiveModal.dismiss('cancel'); + } } ); } else { @@ -587,7 +641,11 @@ export class RteHelperModalComponent { } )[caName] = this.tmpCustomizationArgs[i].value; } - this.ngbActiveModal.close(customizationArgsDict); + if (this.rteHelperBottomSheetRef) { + this.rteHelperBottomSheetRef.dismiss(customizationArgsDict); + } else { + this.ngbActiveModal.close(customizationArgsDict); + } this.customizationArgsFormSubscription.unsubscribe(); } } diff --git a/core/tests/ci-test-suite-configs/acceptance.json b/core/tests/ci-test-suite-configs/acceptance.json index 7ca00079c57a5..9b0b81fc7fb1c 100644 --- a/core/tests/ci-test-suite-configs/acceptance.json +++ b/core/tests/ci-test-suite-configs/acceptance.json @@ -605,6 +605,11 @@ "module": "core/tests/puppeteer-acceptance-tests/specs/topic-manager/create-delete-and-edit-the-stories-and-chapters.spec.ts", "framework": "puppeteer" }, + { + "name": "topic-manager/manage-story-adventures", + "module": "core/tests/puppeteer-acceptance-tests/specs/topic-manager/manage-story-adventures.spec.ts", + "framework": "puppeteer" + }, { "name": "topic-manager/create-and-edit-questions-in-skill-dashboard", "module": "core/tests/puppeteer-acceptance-tests/specs/topic-manager/create-and-edit-questions-in-skill-dashboard.spec.ts", diff --git a/core/tests/playwright-acceptance-tests/specs/community-library-browser/subscribe-to-a-favourite-creator.spec.ts b/core/tests/playwright-acceptance-tests/specs/community-library-browser/subscribe-to-a-favourite-creator.spec.ts index 5ac7afc512326..5e93641fe6a29 100644 --- a/core/tests/playwright-acceptance-tests/specs/community-library-browser/subscribe-to-a-favourite-creator.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/community-library-browser/subscribe-to-a-favourite-creator.spec.ts @@ -83,7 +83,7 @@ test.describe('Community Library Browser', function () { 'Story 1', 'Chapter 1' ); - await communityLibraryBrowser.continueToNextCard(); + await communityLibraryBrowser.continueToNextCardAsLoggedOutUser(); // Subscribe to creator. await communityLibraryBrowser.openLessonInfoModal(); diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/access-pages-that-require-higher-privileges.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/access-pages-that-require-higher-privileges.spec.ts index 99f192069153c..5ee4c29bfd5d4 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/access-pages-that-require-higher-privileges.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/access-pages-that-require-higher-privileges.spec.ts @@ -38,19 +38,19 @@ test.describe('Logged-in Learner', function () { // The logged-in user cannot access the moderator page. test('should be restricted from accessing the moderator page', async function () { - await loggedInUser.navigateToModeratorPage(); + await loggedInUser.navigateToModeratorPageAsLoggedInUser(); await loggedInUser.expectErrorPage(401); // Expect a 401 Unauthorized error. }); // The logged-in user cannot access the topics and skills dashboard. test('should be restricted from accessing the topics and skills dashboard', async function () { - await loggedInUser.navigateToTopicsAndSkillsDashboardPage(); + await loggedInUser.navigateToTopicsAndSkillsDashboardPageAsLoggedInUser(); await loggedInUser.expectErrorPage(401); // Expect a 401 Unauthorized error. }); // The logged-in user cannot access the release coordinator page. test('should be restricted from accessing the release coordinator page', async function () { - await loggedInUser.navigateToReleaseCoordinatorPage(); + await loggedInUser.navigateToReleaseCoordinatorPageAsLoggedInUser(); await loggedInUser.expectErrorPage(404); // Expect a 404 Not Found error. }); diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/changes-site-language-to-rtl.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/changes-site-language-to-rtl.spec.ts index ef35e384c2055..ae456e312f59b 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/changes-site-language-to-rtl.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/changes-site-language-to-rtl.spec.ts @@ -128,7 +128,7 @@ test.describe('Logged-In Learner', function () { await loggedInUser1.expectElementToBeVisible('.mat-mdc-menu-panel', false); - await loggedInUser1.navigateToLearnerDashboard(); + await loggedInUser1.navigateToLearnerDashboardAsLoggedInUser(); await loggedInUser1.verifyPageIsRTL(); @@ -159,7 +159,7 @@ test.describe('Logged-In Learner', function () { await loggedInUser1.verifyPageIsRTL(); // Check hints and lesson info are displayed in RTL. - await loggedInUser1.continueToNextCard(); + await loggedInUser1.continueToNextCardAsLoggedOutUser(); await loggedInUser1.submitAnswer('1'); await loggedInUser1.viewHint(); diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/choose-new-lesson-to-play-from-learner-dashboard.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/choose-new-lesson-to-play-from-learner-dashboard.spec.ts index 15d5673381939..c4090dd184510 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/choose-new-lesson-to-play-from-learner-dashboard.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/choose-new-lesson-to-play-from-learner-dashboard.spec.ts @@ -141,7 +141,7 @@ test.describe('Logged-In Learner', function () { ); await loggedInLearner.page.waitForLoadState('networkidle'); - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); // TODO(#20869): A flaky behaviour is observed due to issue in the backend. // Even after completing the lesson, the node isn't marked as completed. // Once fixed, uncomment the below code. @@ -154,7 +154,7 @@ test.describe('Logged-In Learner', function () { // await loggedInLearner.resumeLessonFromLearnerDashboard( // 'Chapter 2: Test Chapter 2' // ); - // await loggedInLearner.continueToNextCard(); + // await loggedInLearner.continueToNextCardAsLoggedOutUser(); // await loggedInLearner.navigateToLearnerDashboard(); // await loggedInLearner.expectLearnSomethingNewInLDToBeEmpty() diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/completes-the-exploration-and-decides-what-to-do-next.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/completes-the-exploration-and-decides-what-to-do-next.spec.ts index 53158f68f9c0a..0a9dbb8f7757d 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/completes-the-exploration-and-decides-what-to-do-next.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/completes-the-exploration-and-decides-what-to-do-next.spec.ts @@ -52,8 +52,8 @@ test.describe('Logged-In Learner', function () { }); test('should be able to rate the lesson', async function () { - await loggedInUser.playExploration(explorationId); - await loggedInUser.continueToNextCard(); + await loggedInUser.playExplorationAsLoggedInUser(explorationId); + await loggedInUser.continueToNextCardAsLoggedOutUser(); // Rate exploration and give feedback. await loggedInUser.expectRatingStarsToBeVisible(); diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/edit-the-profile.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/edit-the-profile.spec.ts index f9863b4b36e19..633be41fc6820 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/edit-the-profile.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/edit-the-profile.spec.ts @@ -144,7 +144,7 @@ test.describe('Logged-In Learner', function () { await loggedInLearner.saveChangesInPreferencesPage(); await loggedInLearner.page.waitForLoadState('networkidle'); - await loggedInLearner.navigateToSplashPage( + await loggedInLearner.navigateToSplashPageAsLoggedInUser( 'http://localhost:8181/creator-dashboard' ); }); @@ -170,7 +170,7 @@ test.describe('Logged-In Learner', function () { await loggedInLearner.playLessonFromSearchResults( 'Solving problems without calculator' ); - await loggedInLearner.continueToNextCard(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); await loggedInLearner.openLessonInfoModal(); await loggedInLearner.clickOnProfileIconInLessonInfoModel(); diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/export-and-delete-account.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/export-and-delete-account.spec.ts index b079ffe0a16a6..63a340e9c2f67 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/export-and-delete-account.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/export-and-delete-account.spec.ts @@ -46,11 +46,13 @@ test.describe('Logged-In Learner', function () { // Delete Account. await loggedInLearner.deleteAccount(); // Initiating account deletion from /preferences page redirects to /delete-account page. - await loggedInLearner.expectToBeOnPage('delete account'); + await loggedInLearner.expectToBeOnPageAsLoggedInUser('delete account'); await loggedInLearner.confirmAccountDeletion('loggedInLearner'); // After confirmation of account deletion, user is redirected to /pending-account-deletion page. - await loggedInLearner.expectToBeOnPage('pending account deletion'); + await loggedInLearner.expectToBeOnPageAsLoggedInUser( + 'pending account deletion' + ); }); test.afterAll(async function () { diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/interact-with-goals-in-learner-dashboard.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/interact-with-goals-in-learner-dashboard.spec.ts index d16ddc115e8c3..814c86b9762b3 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/interact-with-goals-in-learner-dashboard.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/interact-with-goals-in-learner-dashboard.spec.ts @@ -128,7 +128,7 @@ test.describe('Logged-In Learner', function () { }); test('should start and complete Chapter 1, then show updated progress (33%)', async function () { - await loggedInUser.navigateToLearnerDashboard(); + await loggedInUser.navigateToLearnerDashboardAsLoggedInUser(); await loggedInUser.navigateToGoalsSection(); await loggedInUser.clickOnAddGoalsButtonInRedesignedLearnerDashboard(); @@ -146,14 +146,14 @@ test.describe('Logged-In Learner', function () { await loggedInUser.clickLessonCardButton('What are the Place Values'); await loggedInUser.expectContinueToNextCardButtonToBePresent(true); - await loggedInUser.continueToNextCard(); - await loggedInUser.continueToNextCard(); + await loggedInUser.continueToNextCardAsLoggedOutUser(); + await loggedInUser.continueToNextCardAsLoggedOutUser(); await loggedInUser.expectExplorationCompletionToastMessage( 'Congratulations for completing this lesson!' ); - await loggedInUser.navigateToLearnerDashboard(); + await loggedInUser.navigateToLearnerDashboardAsLoggedInUser(); await loggedInUser.navigateToGoalsSection(); await loggedInUser.expectGoalProgressToBeDisplayed('Place Values', 33); @@ -163,21 +163,21 @@ test.describe('Logged-In Learner', function () { }); test('should complete Chapter 2 and update progress to 67%', async function () { - await loggedInUser.navigateToLearnerDashboard(); + await loggedInUser.navigateToLearnerDashboardAsLoggedInUser(); await loggedInUser.navigateToGoalsSection(); await loggedInUser.clickOnGoalCard('Place Values'); await loggedInUser.clickLessonCardButton('Find the Value of a Number'); await loggedInUser.expectContinueToNextCardButtonToBePresent(true); - await loggedInUser.continueToNextCard(); - await loggedInUser.continueToNextCard(); + await loggedInUser.continueToNextCardAsLoggedOutUser(); + await loggedInUser.continueToNextCardAsLoggedOutUser(); await loggedInUser.expectExplorationCompletionToastMessage( 'Congratulations for completing this lesson!' ); - await loggedInUser.navigateToLearnerDashboard(); + await loggedInUser.navigateToLearnerDashboardAsLoggedInUser(); await loggedInUser.navigateToGoalsSection(); await loggedInUser.expectGoalProgressToBeDisplayed('Place Values', 67); @@ -188,7 +188,7 @@ test.describe('Logged-In Learner', function () { }); test('should complete final chapter and move goal to Completed section', async function () { - await loggedInUser.navigateToLearnerDashboard(); + await loggedInUser.navigateToLearnerDashboardAsLoggedInUser(); await loggedInUser.navigateToGoalsSection(); await loggedInUser.clickOnGoalCard('Place Values'); @@ -196,14 +196,14 @@ test.describe('Logged-In Learner', function () { await loggedInUser.clickLessonCardButton('Comparing Numbers'); await loggedInUser.expectContinueToNextCardButtonToBePresent(true); - await loggedInUser.continueToNextCard(); - await loggedInUser.continueToNextCard(); + await loggedInUser.continueToNextCardAsLoggedOutUser(); + await loggedInUser.continueToNextCardAsLoggedOutUser(); await loggedInUser.expectExplorationCompletionToastMessage( 'Congratulations for completing this lesson!' ); - await loggedInUser.navigateToLearnerDashboard(); + await loggedInUser.navigateToLearnerDashboardAsLoggedInUser(); await loggedInUser.navigateToGoalsSection(); await loggedInUser.expectGoalProgressToBeDisplayed('Place Values', 100); @@ -220,7 +220,7 @@ test.describe('Logged-In Learner', function () { test('should display correctly on mobile viewport', async function () { await loggedInUser.setMobileViewport(); - await loggedInUser.navigateToLearnerDashboard(); + await loggedInUser.navigateToLearnerDashboardAsLoggedInUser(); await loggedInUser.navigateToGoalsSection(); await loggedInUser.expectGoalCardToBeVisible('Place Values'); diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-classroom-progress-in-home-learner-dashboard.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-classroom-progress-in-home-learner-dashboard.spec.ts index 9d4dbcc7cdd4e..709d56848a84e 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-classroom-progress-in-home-learner-dashboard.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-classroom-progress-in-home-learner-dashboard.spec.ts @@ -126,7 +126,7 @@ test.describe('Logged-In Learner', function () { }); test('should have the correct tab title, available sections on landing and Sidebar should contain these items in this order from top to bottom: Profile picture, "Home" button, "Goals" button, "Progress" button', async function () { - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.expectSidebarTabToBeActiveAndContainButtonsInOrder( 'Home' ); @@ -157,29 +157,33 @@ test.describe('Logged-In Learner', function () { test('should navigate directly to math classroom', async function () { await loggedInLearner.navigateToClassroomFromLearnerDashboard('math'); - await loggedInLearner.expectToBeOnPage('learn/math'); + await loggedInLearner.expectToBeOnPageAsLoggedInUser('learn/math'); await loggedInLearner.expectScreenshotToMatch('mathClassroomPage'); showMessage('Navigated to math classroom from learner dashboard.'); }); test('should navigate directly to the Place Values topic in the math classroom', async function () { - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.navigateToTopicPageByCard('Place Values'); - await loggedInLearner.expectToBeOnPage('learn/math/place-values'); + await loggedInLearner.expectToBeOnPageAsLoggedInUser( + 'learn/math/place-values' + ); await loggedInLearner.expectScreenshotToMatch('placeValuesTopicPage'); showMessage('Navigated to Place Values topic from learner dashboard.'); }); test('should display in-progress and recommended lessons after starting a lesson', async function () { - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.navigateToTopicPageByCard('Place Values'); - await loggedInLearner.expectToBeOnPage('learn/math/place-values'); + await loggedInLearner.expectToBeOnPageAsLoggedInUser( + 'learn/math/place-values' + ); await loggedInLearner.selectChapterWithinStoryToLearn( "Jamie's Adventures in the Arcade", 'What are the Place Values' ); - await loggedInLearner.continueToNextCard(); - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); // Did not finish the chapter,So still in In-progress section. await loggedInLearner.expectElementsToBePresentInRLD( ['Continue where you left off', 'Learn Something New'], @@ -214,18 +218,18 @@ test.describe('Logged-In Learner', function () { test('should not recommend any lessons if currently on last lesson', async function () { test.setTimeout(480000); // Takes longer than default timeout. - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.navigateToLessonByCard( 'Lessons in progress', 'Chapter 1: What are the Place Values' ); - await loggedInLearner.continueToNextCard(); - await loggedInLearner.continueToNextCard(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); await loggedInLearner.expectExplorationCompletionToastMessage( 'Congratulations for completing this lesson!' ); - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.expectScreenshotToMatch( 'learnerDashboardHomeTabWithLessonsInProgresschapter2AndRecommendedForYouChapter3' ); @@ -244,13 +248,13 @@ test.describe('Logged-In Learner', function () { 'Lessons in progress', 'Chapter 2: Find the Value of a Number' ); - await loggedInLearner.continueToNextCard(); - await loggedInLearner.continueToNextCard(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); await loggedInLearner.expectExplorationCompletionToastMessage( 'Congratulations for completing this lesson!' ); - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.expectScreenshotToMatch( 'learnerDashboardHomeTabWithLessonsInProgresschapter3AndRecommendedForYouChapter4' ); @@ -270,13 +274,13 @@ test.describe('Logged-In Learner', function () { 'Lessons in progress', 'Chapter 3: Comparing Numbers' ); - await loggedInLearner.continueToNextCard(); - await loggedInLearner.continueToNextCard(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); await loggedInLearner.expectExplorationCompletionToastMessage( 'Congratulations for completing this lesson!' ); - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.expectScreenshotToMatch( 'learnerDashboardHomeTabWithLessonsInProgresschapter4AndRecommendedForYouChapter5' ); @@ -296,12 +300,12 @@ test.describe('Logged-In Learner', function () { 'Lessons in progress', 'Chapter 4: Rounding Numbers part 1' ); - await loggedInLearner.continueToNextCard(); - await loggedInLearner.continueToNextCard(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); await loggedInLearner.expectExplorationCompletionToastMessage( 'Congratulations for completing this lesson!' ); - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.expectScreenshotToMatch( 'learnerDashboardHomeTabWithLessonsInProgresschapter5AndRecommendedForYouChapter6' ); @@ -315,13 +319,13 @@ test.describe('Logged-In Learner', function () { 'Lessons in progress', 'Chapter 5: Rounding Numbers part 2' ); - await loggedInLearner.continueToNextCard(); - await loggedInLearner.continueToNextCard(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); await loggedInLearner.expectExplorationCompletionToastMessage( 'Congratulations for completing this lesson!' ); - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.expectScreenshotToMatch( 'learnerDashboardHomeTabWithLessonsInProgresschapter6AndNoRecommendedForYouChapter' ); diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-community-lesson-progress-in-home-learner-dashboard.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-community-lesson-progress-in-home-learner-dashboard.spec.ts index ea74d7cd36fbd..971755ed6a1a1 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-community-lesson-progress-in-home-learner-dashboard.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-community-lesson-progress-in-home-learner-dashboard.spec.ts @@ -77,15 +77,15 @@ test.describe('Logged-In Learner', function () { }); test('should be able to see community lessons in In Progress section if not completed fully', async function () { - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.navigateToCommunityLibraryOnNavbar(); await loggedInLearner.expectToBeOnCommunityLibraryPage(); await loggedInLearner.searchForLessonInSearchBar('Explore Title 1'); await loggedInLearner.playLessonFromSearchResults('Explore Title 1'); - await loggedInLearner.continueToNextCard(); - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.expectScreenshotToMatch( 'learnerDashboardHomeTabWithLessonsInProgressExploreTitle1' ); diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-goals-in-learner-dashboard.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-goals-in-learner-dashboard.spec.ts index 73dd86fae0a81..7b0bf43b2693d 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-goals-in-learner-dashboard.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-goals-in-learner-dashboard.spec.ts @@ -127,7 +127,7 @@ test.describe('Logged-In Learner - Manage Goals', function () { }); test('should display empty Goals tab with title and Add Goals button', async function () { - await loggedInUser.navigateToLearnerDashboard(); + await loggedInUser.navigateToLearnerDashboardAsLoggedInUser(); await loggedInUser.navigateToGoalsSection(); await loggedInUser.expectLearnerGreetingsToBe("loggedInUser1's Goals"); @@ -222,7 +222,7 @@ test.describe('Logged-In Learner - Manage Goals', function () { }); test('should show goal card with 0% and Start button after adding goal', async function () { - await loggedInUser.navigateToLearnerDashboard(); + await loggedInUser.navigateToLearnerDashboardAsLoggedInUser(); await loggedInUser.navigateToGoalsSection(); await loggedInUser.addGoalInRedesignedLearnerDashboard('Place Values'); @@ -252,7 +252,7 @@ test.describe('Logged-In Learner - Manage Goals', function () { }); test('should highlight Goals tab in sidebar', async function () { - await loggedInUser.navigateToLearnerDashboard(); + await loggedInUser.navigateToLearnerDashboardAsLoggedInUser(); await loggedInUser.expectGoalsTabButtonToBeVisible(); await loggedInUser.navigateToGoalsSection(); diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-in-progress-and-completed-lessons-and-skill.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-in-progress-and-completed-lessons-and-skill.spec.ts index 5030b42d414ee..6fabf4c233f22 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-in-progress-and-completed-lessons-and-skill.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/manage-in-progress-and-completed-lessons-and-skill.spec.ts @@ -116,7 +116,7 @@ test.describe('Logged-in Learner', function () { }); test('should display empty progress message when no lessons are in progress', async function () { - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.expectSidebarTabToBeActiveAndContainButtonsInOrder( 'Home' ); @@ -129,13 +129,13 @@ test.describe('Logged-in Learner', function () { }); test('should select "Or Explore All Lessons in Classroom" button and navigate to /learn/math', async function () { - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.navigateToProgressSection(); await loggedInLearner.expectClassroomButtonOnRedesignedLearnerDashboardToBePresent( true ); await loggedInLearner.navigateThroughClassroomButtonOnRLD(); - await loggedInLearner.expectToBeOnPage('/learn/math'); + await loggedInLearner.expectToBeOnPageAsLoggedInUser('/learn/math'); }); test('should select Place Values Topic and play "Chapter 1: What are the Place Values?" but do not finish and see It in Progress Section', async function () { @@ -144,9 +144,9 @@ test.describe('Logged-in Learner', function () { "Jamie's Adventures in the Arcade", 'What are the Place Values' ); - await loggedInLearner.continueToNextCard(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.navigateToProgressSection(); await loggedInLearner.expectScreenshotToMatch( @@ -171,13 +171,13 @@ test.describe('Logged-in Learner', function () { 'Classroom Lessons', 'Chapter 1: What are the Place Values' ); - await loggedInLearner.continueToNextCard(); - await loggedInLearner.continueToNextCard(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); await loggedInLearner.expectExplorationCompletionToastMessage( 'Congratulations for completing this lesson!' ); - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.navigateToProgressSection(); await loggedInLearner.expectScreenshotToMatch( 'ProgressSectionInProgressWithOnlyChapter02' @@ -185,7 +185,7 @@ test.describe('Logged-in Learner', function () { }); test("should complete all the lessons of Place Value's Story and see Chapter 1 in the Completed Lessons section", async function () { - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.navigateToProgressSection(); await loggedInLearner.expectLessonCardProgressToBe( @@ -199,13 +199,13 @@ test.describe('Logged-in Learner', function () { 'Classroom Lessons', 'Chapter 2: Find the Value of a Number' ); - await loggedInLearner.continueToNextCard(); - await loggedInLearner.continueToNextCard(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); await loggedInLearner.expectExplorationCompletionToastMessage( 'Congratulations for completing this lesson!' ); - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.navigateToProgressSection(); await loggedInLearner.expectLessonCardProgressToBe( 'Classroom Lessons', @@ -218,13 +218,13 @@ test.describe('Logged-in Learner', function () { 'Classroom Lessons', 'Chapter 3: Comparing Numbers' ); - await loggedInLearner.continueToNextCard(); - await loggedInLearner.continueToNextCard(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); await loggedInLearner.expectExplorationCompletionToastMessage( 'Congratulations for completing this lesson!' ); - await loggedInLearner.navigateToLearnerDashboard(); + await loggedInLearner.navigateToLearnerDashboardAsLoggedInUser(); await loggedInLearner.navigateToProgressSection(); await loggedInLearner.expectScreenshotToMatch( diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/provide-feedback-on-the-lesson-or-report-it-from-the-lesson-player.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/provide-feedback-on-the-lesson-or-report-it-from-the-lesson-player.spec.ts index 509b477447c01..edd03e64a1cb3 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/provide-feedback-on-the-lesson-or-report-it-from-the-lesson-player.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/provide-feedback-on-the-lesson-or-report-it-from-the-lesson-player.spec.ts @@ -112,7 +112,7 @@ test.describe('Logged-In Learner', function () { await loggedInLearner.navigateToCommunityLibraryPage(); await loggedInLearner.searchForLessonInSearchBar('Algebra Basics'); await loggedInLearner.playLessonFromSearchResults('Algebra Basics'); - await loggedInLearner.continueToNextCard(); + await loggedInLearner.continueToNextCardAsLoggedOutUser(); // Report Exploration. await loggedInLearner.reportExploration('It is an ad'); diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts index bee0d9cef5673..a1e6797373541 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts @@ -112,9 +112,9 @@ test.describe('Logged-in User', function () { await loggedInUser.playLessonFromSearchResults('Positive Numbers'); // Continue to the next card and submit an answer. - await loggedInUser.continueToNextCard(); + await loggedInUser.continueToNextCardAsLoggedOutUser(); await loggedInUser.submitAnswer('-25'); - await loggedInUser.continueToNextCard(); + await loggedInUser.continueToNextCardAsLoggedOutUser(); // Verify that the checkpoint modal appears and reload the page. await loggedInUser.verifyCheckpointModalAppears(); @@ -126,7 +126,7 @@ test.describe('Logged-in User', function () { await loggedInUser.expectProgressReminder(true); await loggedInUser.chooseActionInProgressRemainder('Resume'); - await loggedInUser.continueToNextCard(); + await loggedInUser.continueToNextCardAsLoggedOutUser(); await loggedInUser.expectCardContentToMatch( 'Lesson completed successfully. We have practiced negative numbers.' ); @@ -140,9 +140,9 @@ test.describe('Logged-in User', function () { // Continue the exploration from where they left off. await loggedInUser.chooseActionInProgressRemainder('Restart'); - await loggedInUser.continueToNextCard(); + await loggedInUser.continueToNextCardAsLoggedOutUser(); await loggedInUser.submitAnswer('-99'); - await loggedInUser.continueToNextCard(); + await loggedInUser.continueToNextCardAsLoggedOutUser(); }); test.afterAll(async function () { diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/starts-from-beginning-after-completing-a-lesson.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/starts-from-beginning-after-completing-a-lesson.spec.ts index 7df4f59ba17ad..b85e7538ba401 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/starts-from-beginning-after-completing-a-lesson.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/starts-from-beginning-after-completing-a-lesson.spec.ts @@ -110,7 +110,7 @@ test.describe('Logged-In User', function () { // TODO(#20563): When a user revisits an exploration after completing it, // the exploration should start from the beginning, not from the previous checkpoint. // see: https://github.com/oppia/oppia/issues/20563. - await loggedInUser.navigateToLearnerDashboard(); + await loggedInUser.navigateToLearnerDashboardAsLoggedInUser(); }); test.afterAll(async function () { diff --git a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/access-the-lesson-player.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/access-the-lesson-player.spec.ts index c4f19417a1574..53be0704d6151 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/access-the-lesson-player.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/access-the-lesson-player.spec.ts @@ -61,14 +61,14 @@ test.describe('Logged-Out Learner', function () { const wrongExplorationId = explorationId?.slice(5) ?? '' + explorationId?.slice(0, 5); - await loggedOutLearner.playExploration(wrongExplorationId); + await loggedOutLearner.playExplorationAsLoggedOutUser(wrongExplorationId); await loggedOutLearner.expectToBeOnErrorPage(404); }); test('should be able to access existent lesson', async function () { // Navigate to exploration, verify URL, and match screenshot. - await loggedOutLearner.playExploration(explorationId); - await loggedOutLearner.expectToBeOnPage('/explore/'); + await loggedOutLearner.playExplorationAsLoggedOutUser(explorationId); + await loggedOutLearner.expectToBeOnPageAsLoggedOutUser('/explore/'); await loggedOutLearner.expectExplorationCompletionToastMessage( 'Congratulations for completing this lesson!' diff --git a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/complete-the-embedded-lesson.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/complete-the-embedded-lesson.spec.ts index 363aa1eb2d7df..7b8654ea6e2b9 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/complete-the-embedded-lesson.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/complete-the-embedded-lesson.spec.ts @@ -110,7 +110,7 @@ test.describe('Logged-Out Learner in Embedded Lesson', function () { // Play until checkpoint. await loggedOutUser.submitAnswer('0'); await loggedOutUser.expectContinueToNextCardButtonToBePresent(); - await loggedOutUser.continueToNextCard(); + await loggedOutUser.continueToNextCardAsLoggedOutUser(); // TODO(#24066): Verify checkpoint behavior. Currently, the expected behavior is not observed. diff --git a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/discover-the-website-and-navigate-to-math-classroom.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/discover-the-website-and-navigate-to-math-classroom.spec.ts index 7639a7d8f159b..c78f33e1cff10 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/discover-the-website-and-navigate-to-math-classroom.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/discover-the-website-and-navigate-to-math-classroom.spec.ts @@ -67,7 +67,7 @@ test.describe('Logged-Out Learner', function () { 'Algebra', 'fractions' ); - await curriculumAdmin.navigateToTopicAndSkillsDashboardPage(); + await curriculumAdmin.navigateToTopicsAndSkillsDashboardPageAsTopicManager(); await curriculumAdmin.openSkillEditor('fractions'); await curriculumAdmin.navigateToSkillQuestionEditorTab(); @@ -101,7 +101,7 @@ test.describe('Logged-Out Learner', function () { }); test('should be able to find list of subjects to learn', async function () { - await loggedOutLearner.navigateToSplashPage(); + await loggedOutLearner.navigateToSplashPageAsLoggedOutUser(); await loggedOutLearner.expectHomePageTitleToBe( 'Free Education for Everyone' ); diff --git a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/give-feedback-on-the-lesson-from-the-lesson-player.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/give-feedback-on-the-lesson-from-the-lesson-player.spec.ts index 65f9a8655bc12..059bc745e761e 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/give-feedback-on-the-lesson-from-the-lesson-player.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/give-feedback-on-the-lesson-from-the-lesson-player.spec.ts @@ -92,7 +92,7 @@ test.describe('Logged-Out Learner', function () { }); test('should be able to give feedback from the navbar', async function () { - await loggedOutLearner.playExploration(explorationId); + await loggedOutLearner.playExplorationAsLoggedOutUser(explorationId); // Open Feedback popup and check "Stay Anonymous" text isn't visible. await loggedOutLearner.openFeedbackPopup(); diff --git a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/listen-to-voiceovers-of-the-lessons-in-the-lesson-player.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/listen-to-voiceovers-of-the-lessons-in-the-lesson-player.spec.ts index 0098f29e922fc..818b9ac6ff408 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/listen-to-voiceovers-of-the-lessons-in-the-lesson-player.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/listen-to-voiceovers-of-the-lessons-in-the-lesson-player.spec.ts @@ -219,7 +219,7 @@ test.describe('Logged-Out Learner', function () { await loggedOutLearner.expectVoiceoverIsPlayable(false); // Check audio (voiceover) avaibility in next card. - await loggedOutLearner.continueToNextCard(); + await loggedOutLearner.continueToNextCardAsLoggedOutUser(); await loggedOutLearner.expectVoiceoverIsPlayable(); // Play Voiceovers. @@ -230,17 +230,17 @@ test.describe('Logged-Out Learner', function () { test('should be able to change the audio language', async function () { // Play voiceovers in Hindi. - await loggedOutLearner.playExploration(explorationId); + await loggedOutLearner.playExplorationAsLoggedOutUser(explorationId); await loggedOutLearner.changeLessonLanguage('hi'); - await loggedOutLearner.continueToNextCard(); + await loggedOutLearner.continueToNextCardAsLoggedOutUser(); await loggedOutLearner.expectVoiceoverIsPlayable(); }); test('should be able to skip some parts of audio', async function () { await loggedOutLearner.reloadPage(); await loggedOutLearner.changeLessonLanguage('hi'); - await loggedOutLearner.continueToNextCard(); + await loggedOutLearner.continueToNextCardAsLoggedOutUser(); await loggedOutLearner.expectVoiceoverIsSkippable(); }); diff --git a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/pick-a-lesson-to-learn.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/pick-a-lesson-to-learn.spec.ts index 4096acdea6e04..cb309fcdaac50 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/pick-a-lesson-to-learn.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/pick-a-lesson-to-learn.spec.ts @@ -140,10 +140,10 @@ test.describe('Logged-Out Learner', function () { await loggedOutLearner.expectCardContentToMatch( 'Hello, World! This is a test.' ); - await loggedOutLearner.continueToNextCard(); + await loggedOutLearner.continueToNextCardAsLoggedOutUser(); await loggedOutLearner.verifyCheckpointModalAppears(); await loggedOutLearner.submitAnswerInTextArea('Hello, Oppia!'); - await loggedOutLearner.continueToNextCard(); + await loggedOutLearner.continueToNextCardAsLoggedOutUser(); await loggedOutLearner.expectExplorationCompletionToastMessage( 'Congratulations for completing this lesson!' @@ -194,7 +194,7 @@ test.describe('Logged-Out Learner', function () { // Progress Info. await loggedOutLearner.expectNoSaveProgressBeforeCheckpointInfo(); await loggedOutLearner.closeLessonInfoModal(); - await loggedOutLearner.continueToNextCard(); + await loggedOutLearner.continueToNextCardAsLoggedOutUser(); await loggedOutLearner.verifyCheckpointModalAppears(); await loggedOutLearner.openLessonInfoModal(); await loggedOutLearner.saveProgress(); @@ -209,7 +209,7 @@ test.describe('Logged-Out Learner', function () { test('should be able to go to the next lesson', async function () { await loggedOutLearner.submitAnswerInTextArea('Hello, Oppia!'); - await loggedOutLearner.continueToNextCard(); + await loggedOutLearner.continueToNextCardAsLoggedOutUser(); await loggedOutLearner.expectExplorationCompletionToastMessage( 'Congratulations for completing this lesson!' diff --git a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/play-a-complete-community-lesson.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/play-a-complete-community-lesson.spec.ts index 4429d8b95c28f..f39ac1d7ad596 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/play-a-complete-community-lesson.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/play-a-complete-community-lesson.spec.ts @@ -53,7 +53,7 @@ test.describe('Logged-Out Learner', function () { [ROLES.CURRICULUM_ADMIN] ); - await curriculumAdmin.navigateToTopicAndSkillsDashboardPage(); + await curriculumAdmin.navigateToTopicsAndSkillsDashboardPageAsTopicManager(); await curriculumAdmin.createTopic('Introduction to Oppia', 'intro-oppia'); await curriculumAdmin.createSkillForTopic( 'Math', @@ -150,7 +150,7 @@ test.describe('Logged-Out Learner', function () { await loggedOutLearner.playLessonFromSearchResults( 'What are the place values?' ); - await loggedOutLearner.expectToBeOnPage( + await loggedOutLearner.expectToBeOnPageAsLoggedOutUser( `http://localhost:8181/explore/${explorationId}` ); await loggedOutLearner.waitForPageToFullyLoad(); @@ -158,7 +158,7 @@ test.describe('Logged-Out Learner', function () { await loggedOutLearner.expectLessonInfoTextToBe('Lesson Info'); // Continue to next card. - await loggedOutLearner.continueToNextCard(); + await loggedOutLearner.continueToNextCardAsLoggedOutUser(); await loggedOutLearner.expectGoBackToPreviousCardButton(true); await loggedOutLearner.expectContinueToNextCardButtonToBePresent(false); diff --git a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts index 4eb130386de8f..2b00b7b2ebd1a 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts @@ -110,8 +110,8 @@ test.describe('Logged-out User', function () { }); test('should be able to resume progress using 72-hour link.', async function () { - await loggedOutLearner.playExploration(explorationId); - await loggedOutLearner.continueToNextCard(); + await loggedOutLearner.playExplorationAsLoggedOutUser(explorationId); + await loggedOutLearner.continueToNextCardAsLoggedOutUser(); await loggedOutLearner.verifyCheckpointModalAppears(); await loggedOutLearner.openLessonInfoModal(); diff --git a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/search-for-a-specific-exploration.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/search-for-a-specific-exploration.spec.ts index 6035842d42676..29e81755d3b73 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/search-for-a-specific-exploration.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/search-for-a-specific-exploration.spec.ts @@ -56,8 +56,8 @@ test.describe('Logged-Out Learner', function () { 'Mathematics' ); - await explorationEditor.playExploration(explorationId); - await explorationEditor.continueToNextCard(); + await explorationEditor.playExplorationAsLoggedInUser(explorationId); + await explorationEditor.continueToNextCardAsExplorationEditor(); await explorationEditor.rateExploration( 5, 'Excellent advanced Algebra course', diff --git a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/share-the-lesson-from-the-lesson-player.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/share-the-lesson-from-the-lesson-player.spec.ts index 2fde20252cbfd..dd811f39e5a57 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/share-the-lesson-from-the-lesson-player.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/share-the-lesson-from-the-lesson-player.spec.ts @@ -116,8 +116,8 @@ test.describe('Logged-Out Learner', function () { }); test('should be able to share the lesson using copy link', async function () { - await loggedOutUser.playExploration(explorationId); - await loggedOutUser.continueToNextCard(); + await loggedOutUser.playExplorationAsLoggedOutUser(explorationId); + await loggedOutUser.continueToNextCardAsLoggedOutUser(); await loggedOutUser.generateAttribution(); await loggedOutUser.expectAttributionInHtmlSectionToBe( diff --git a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/use-the-feedback-and-help-cards.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/use-the-feedback-and-help-cards.spec.ts index 10d9d11eb7ac0..e7b468b5cff7f 100644 --- a/core/tests/playwright-acceptance-tests/specs/logged-out-learner/use-the-feedback-and-help-cards.spec.ts +++ b/core/tests/playwright-acceptance-tests/specs/logged-out-learner/use-the-feedback-and-help-cards.spec.ts @@ -137,7 +137,7 @@ test.describe('Logged-Out Learner', function () { }); test('should be able to continue to next card', async function () { - await loggedOutLearner.continueToNextCard(); + await loggedOutLearner.continueToNextCardAsLoggedOutUser(); await loggedOutLearner.expectCardContentToMatch( 'Give fraction with denominator 2.' ); @@ -184,17 +184,18 @@ test.describe('Logged-Out Learner', function () { test('should be able to learn again on wrong answer', async function () { await loggedOutLearner.submitAnswer('2/9'); await loggedOutLearner.expectNextCardButtonTextToBe('LEARN AGAIN'); - await loggedOutLearner.continueToNextCard(); + // We start from the first card after clicking on the LEARN AGAIN button. So there is no back button on the first card. + await loggedOutLearner.continueToNextCardAsLoggedOutUser(true); await loggedOutLearner.expectCardContentToMatch( 'Welcome, to the Place Values Exploration.' ); - await loggedOutLearner.continueToNextCard(); + await loggedOutLearner.continueToNextCardAsLoggedOutUser(); }); test('should be able to submit a correct answer and see the celebration pop-up', async function () { await loggedOutLearner.submitAnswer('1/2'); - await loggedOutLearner.continueToNextCard(); + await loggedOutLearner.continueToNextCardAsLoggedOutUser(); await loggedOutLearner.expectExplorationCompletionToastMessage( 'Congratulations for completing this lesson!' ); diff --git a/core/tests/playwright-acceptance-tests/utilities/common/exploration-editor.ts b/core/tests/playwright-acceptance-tests/utilities/common/exploration-editor-utils.ts similarity index 70% rename from core/tests/playwright-acceptance-tests/utilities/common/exploration-editor.ts rename to core/tests/playwright-acceptance-tests/utilities/common/exploration-editor-utils.ts index 4a2e10d365e2d..c187521d46cc0 100644 --- a/core/tests/playwright-acceptance-tests/utilities/common/exploration-editor.ts +++ b/core/tests/playwright-acceptance-tests/utilities/common/exploration-editor-utils.ts @@ -20,14 +20,38 @@ import {BaseUser} from './playwright-utils'; import {showMessage} from './show-message'; const dismissWelcomeModalSelector = 'button.e2e-test-dismiss-welcome-modal'; +const nextCardButton = '.e2e-test-next-card-button'; +const nextCardArrowButton = '.e2e-test-next-button'; +const previousCardButton = '.e2e-test-back-button'; -export class ExplorationEditorModal { +export class ExplorationEditorUtils { userInstance: BaseUser; constructor(userInstance: BaseUser) { this.userInstance = userInstance; } + /** + * Function to navigate to the next card in the preview tab. + * @param {boolean} skipVerification - Whether to skip verification of the card content. + */ + async continueToNextCard(skipVerification: boolean = false): Promise { + try { + await this.userInstance.clickOnElementWithSelector(nextCardButton); + } catch (error) { + if (error instanceof Error && error.message.includes('Timeout')) { + await this.userInstance.clickOnElementWithSelector(nextCardArrowButton); + } else { + throw error; + } + } + + if (skipVerification) { + return; + } + await this.userInstance.expectElementToBeVisible(previousCardButton); + } + /** * Function to dismiss exploration editor welcome modal. * @param {boolean} failIfMissing - Whether to fail if the welcome modal is not found. diff --git a/core/tests/playwright-acceptance-tests/utilities/common/navigation-utils.ts b/core/tests/playwright-acceptance-tests/utilities/common/navigation-utils.ts new file mode 100644 index 0000000000000..f7e647515e4e6 --- /dev/null +++ b/core/tests/playwright-acceptance-tests/utilities/common/navigation-utils.ts @@ -0,0 +1,122 @@ +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Utility class for navigation actions shared across multiple + * user roles (e.g. LoggedInUser, LoggedOutUser). Extracted here to avoid + * duplicate implementations of the same action across user utility files. + */ + +import {expect} from '@playwright/test'; +import {BaseUser} from './playwright-utils'; +import testConstants from './test-constants'; + +const learnerDashboardUrl = testConstants.URLs.LearnerDashboard; +const moderatorPageUrl = testConstants.URLs.ModeratorPage; +const releaseCoordinatorPageUrl = testConstants.URLs.ReleaseCoordinator; +const splashPageUrl = testConstants.URLs.splash; +const topicsAndSkillsDashboardUrl = testConstants.URLs.TopicAndSkillsDashboard; + +const homeTabSectionInLearnerDashboard = '.e2e-test-learner-dash-home-tab'; + +export class NavigationUtils { + userInstance: BaseUser; + + constructor(userInstance: BaseUser) { + this.userInstance = userInstance; + } + + /** + * Verifies that the current page URL includes the expected page pathname. + * @param {string} expectedPage - The expected page pathname (e.g., 'learner-dashboard'). + */ + async expectToBeOnPage(expectedPage: string): Promise { + await this.userInstance.waitForStaticAssetsToLoad(); + const url = this.userInstance.page.url(); + + // Replace spaces in the expectedPage with hyphens. + const expectedPageInUrl = expectedPage.replace(/\s+/g, '-'); + + if (!url.toLowerCase().includes(expectedPageInUrl.toLowerCase())) { + throw new Error( + `Expected to be on page ${expectedPage}, but found ${url}` + ); + } + } + + /** + * Navigates to the learner dashboard. + * @param {boolean} verifyUrl - Whether to verify the URL after navigation. + */ + async navigateToLearnerDashboard(verifyUrl: boolean = true): Promise { + await this.userInstance.goto(learnerDashboardUrl, verifyUrl); + await this.userInstance.waitForPageToFullyLoad(); + if (verifyUrl) { + await this.userInstance.expectElementToBeAttachedInDOM( + homeTabSectionInLearnerDashboard + ); + } + } + + /** + * Navigates to the Moderator page. + * @param {boolean} verifyUrl - Whether to verify the URL after navigation. + */ + async navigateToModeratorPage(verifyUrl: boolean = true): Promise { + await this.userInstance.goto(moderatorPageUrl, verifyUrl); + } + + /** + * Navigates to the Release Coordinator page. + */ + async navigateToReleaseCoordinatorPage(): Promise { + await this.userInstance.goto(releaseCoordinatorPageUrl); + } + + /** + * Navigates to the splash page and verifies the resulting URL. Since + * /splash redirects the user to a different page depending on their auth + * state, the expected destination must be supplied by the caller rather + * than assumed here. + * @param {string} expectedURL - The expected URL after navigation. + */ + async navigateToSplashPage(expectedURL: string): Promise { + // We explicitly check for expected URL instead of verifying it through + // BaseUser.goto as /splash redirects user to a different page. + await this.userInstance.goto(splashPageUrl, false); + + expect(this.userInstance.page.url()).toBe(expectedURL); + } + + /** + * Navigates to the Topics and Skills Dashboard page. + */ + async navigateToTopicsAndSkillsDashboardPage(): Promise { + await this.userInstance.goto(topicsAndSkillsDashboardUrl); + } + + /** + * Navigates to and plays an exploration by its ID. + * @param {string} baseUrl - The base URL of the Oppia instance. + * @param {string | null} explorationId - The ID of the exploration to play. + */ + async playExploration( + baseUrl: string, + explorationId: string | null + ): Promise { + await this.userInstance.goto( + `${baseUrl}/explore/${explorationId as string}` + ); + } +} diff --git a/core/tests/playwright-acceptance-tests/utilities/common/state-editor-utils.ts b/core/tests/playwright-acceptance-tests/utilities/common/state-editor-utils.ts new file mode 100644 index 0000000000000..f5a1a538e07b8 --- /dev/null +++ b/core/tests/playwright-acceptance-tests/utilities/common/state-editor-utils.ts @@ -0,0 +1,124 @@ +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Utility class for state editor actions shared across + * multiple user roles (e.g. ExplorationEditor, PracticeQuestionSubmitter). + * Extracted here to avoid duplicate implementations of the same action + * across user utility files. See issue #22539. + */ + +import {BaseUser} from './playwright-utils'; + +const defaultFeedbackTab = 'a.e2e-test-default-response-tab'; +const openOutcomeFeedBackEditor = 'div.e2e-test-open-outcome-feedback-editor'; +const stateContentInputField = 'div.e2e-test-rte'; +const saveOutcomeFeedbackButton = 'button.e2e-test-save-outcome-feedback'; +const openOutcomeDestButton = '.e2e-test-open-outcome-dest-editor'; +const destinationSelectorDropdown = '.e2e-test-destination-selector-dropdown'; +const saveDestinationButtonSelector = '.e2e-test-save-outcome-dest'; +const saveOutcomeDestButton = '.e2e-test-save-outcome-dest'; +const outcomeDestWhenStuckSelector = + '.protractor-test-open-outcome-dest-if-stuck-editor'; +const destinationWhenStuckSelectorDropdown = + '.e2e-test-destination-when-stuck-selector-dropdown'; +const addDestinationStateWhenStuckInput = '.protractor-test-add-state-input'; +const saveStuckDestinationButtonSelector = '.e2e-test-save-stuck-destination'; + +export class StateEditorUtils { + userInstance: BaseUser; + + constructor(userInstance: BaseUser) { + this.userInstance = userInstance; + } + + /** + * Function to add feedback for default responses of a state interaction. + * @param {string} defaultResponseFeedback - The feedback for the default responses. + * @param {string} [directToCard] - The card to direct to (optional). + * @param {string} [directToCardWhenStuck] - The card to direct to when the learner is stuck (optional). + */ + async editDefaultResponseFeedback( + defaultResponseFeedback: string, + directToCard?: string, + directToCardWhenStuck?: string + ): Promise { + await this.userInstance.clickOnElementWithSelector(defaultFeedbackTab); + + if (defaultResponseFeedback) { + await this.updateDefaultResponseFeedbackInExplorationEditorPage( + defaultResponseFeedback + ); + } + + if (directToCard) { + await this.userInstance.clickOnElementWithSelector(openOutcomeDestButton); + await this.userInstance.select(destinationSelectorDropdown, directToCard); + await this.userInstance.clickOnElementWithSelector( + saveDestinationButtonSelector + ); + await this.userInstance.expectElementToBeVisible( + saveDestinationButtonSelector, + false + ); + } + + if (directToCardWhenStuck) { + await this.userInstance.clickOnElementWithSelector( + outcomeDestWhenStuckSelector + ); + // The '4: /' value is used to select the 'a new card called' option in the dropdown. + await this.userInstance.select( + destinationWhenStuckSelectorDropdown, + '4: /' + ); + await this.userInstance.typeInInputField( + addDestinationStateWhenStuckInput, + directToCardWhenStuck + ); + await this.userInstance.clickOnElementWithSelector( + saveStuckDestinationButtonSelector + ); + await this.userInstance.expectElementToBeVisible( + saveStuckDestinationButtonSelector, + false + ); + } + } + + /** + * Function to update the default response feedback for a state interaction. + * @param {string} defaultResponseFeedback - The feedback for the default responses. + */ + async updateDefaultResponseFeedbackInExplorationEditorPage( + defaultResponseFeedback: string + ): Promise { + await this.userInstance.clickOnElementWithSelector( + openOutcomeFeedBackEditor + ); + await this.userInstance.clickOnElementWithSelector(stateContentInputField); + await this.userInstance.typeInInputField( + stateContentInputField, + defaultResponseFeedback + ); + await this.userInstance.clickOnElementWithSelector( + saveOutcomeFeedbackButton + ); + + await this.userInstance.expectElementToBeVisible( + saveOutcomeDestButton, + false + ); + } +} diff --git a/core/tests/playwright-acceptance-tests/utilities/common/test-constants.ts b/core/tests/playwright-acceptance-tests/utilities/common/test-constants.ts index 5e66ba91fd4ca..51df3d7644708 100644 --- a/core/tests/playwright-acceptance-tests/utilities/common/test-constants.ts +++ b/core/tests/playwright-acceptance-tests/utilities/common/test-constants.ts @@ -98,9 +98,6 @@ export default { 'http://localhost:8181/topics-and-skills-dashboard', ProgrammingWithCarla: 'https://www.oppia.org/collection/inDXV0w8-p1C', Volunteer: 'http://localhost:8181/volunteer', - VolunteerForm: - 'https://docs.google.com/forms/d/e/1FAIpQLSc5_rwUjugT_Jt_EB49_zAKWVY68I3fTXF5w9b5faIk7rL6yg/viewform', - VolunteerFormShortUrl: 'https://forms.gle/rhFYoLLSFr3JEZHy8', WelcomeToOppia: 'https://www.oppia.org/explore/0', WikiPrivilegesToFirebaseAccount: 'https://github.com/oppia/oppia/wiki/#2-add-custom-claims-to-a-firebase-account', diff --git a/core/tests/playwright-acceptance-tests/utilities/common/user-factory.ts b/core/tests/playwright-acceptance-tests/utilities/common/user-factory.ts index b209356dcf751..c3db29952c706 100644 --- a/core/tests/playwright-acceptance-tests/utilities/common/user-factory.ts +++ b/core/tests/playwright-acceptance-tests/utilities/common/user-factory.ts @@ -98,11 +98,32 @@ export class UserFactory { TUser extends BaseUser, TRoles extends BaseUser[], >(user: TUser, roles: TRoles): TUser & UnionToIntersection { + const userPrototype = Object.getPrototypeOf(user); + + // Track which role (by constructor name) defined which method name + // within THIS composition call only. This lets us catch two different + // roles genuinely colliding on a name, without falsely flagging the + // same role being re-composed onto a prototype it already touched in + // an earlier, unrelated createNewUser() call. + const namesDefinedInThisCall = new Map(); + for (const role of roles) { - const userPrototype = Object.getPrototypeOf(user); const rolePrototype = Object.getPrototypeOf(role); + const roleName = rolePrototype.constructor.name; Object.getOwnPropertyNames(rolePrototype).forEach((name: string) => { + if (name === 'constructor') { + return; + } + + const definedByRoleName = namesDefinedInThisCall.get(name); + if (definedByRoleName && definedByRoleName !== roleName) { + throw new Error( + `Method '${name}' is defined by both '${definedByRoleName}' and '${roleName}'. Function name collision detected.` + ); + } + namesDefinedInThisCall.set(name, roleName); + Object.defineProperty( userPrototype, name, diff --git a/core/tests/playwright-acceptance-tests/utilities/user/curriculum-admin.ts b/core/tests/playwright-acceptance-tests/utilities/user/curriculum-admin.ts index 3f2643c936cfc..ff30b189d2c4d 100644 --- a/core/tests/playwright-acceptance-tests/utilities/user/curriculum-admin.ts +++ b/core/tests/playwright-acceptance-tests/utilities/user/curriculum-admin.ts @@ -16,7 +16,7 @@ * @fileoverview Curriculum Admin users utility file. */ -import {Page, ElementHandle, expect} from '@playwright/test'; +import {Page} from '@playwright/test'; import testConstants from '../common/test-constants'; import {showMessage} from '../common/show-message'; import {TopicManager} from './topic-manager'; @@ -25,73 +25,8 @@ const baseURL = testConstants.URLs.BaseURL; const curriculumAdminThumbnailImage = testConstants.data.curriculumAdminThumbnailImage; const classroomBannerImage = testConstants.data.classroomBannerImage; -const classroomAdminUrl = testConstants.URLs.ClassroomAdmin; -const topicAndSkillsDashboardUrl = testConstants.URLs.TopicAndSkillsDashboard; -const richTextAreaField = 'div.e2e-test-rte'; - -const modalDiv = 'div.modal-content'; -const changeSubtopicAssignmentModal = - '.oppia-change-subtopic-assignment-modal div.modal-content'; -const closeSaveModalButton = '.e2e-test-close-save-modal-button'; - -const photoBoxButton = 'div.e2e-test-photo-button'; -const subtopicPhotoBoxButton = - '.e2e-test-subtopic-thumbnail .e2e-test-photo-button'; const uploadPhotoButton = 'button.e2e-test-photo-upload-submit'; -const photoUploadModal = 'edit-thumbnail-modal'; - -const topicsTab = 'a.e2e-test-topics-tab'; -const desktopTopicSelector = 'a.e2e-test-topic-name'; -const topicNameField = 'input.e2e-test-new-topic-name-field'; -const topicUrlFragmentField = - '.e2e-test-new-topic-url-fragment-field .e2e-test-url-fragment-field'; -const topicWebFragmentField = 'input.e2e-test-new-page-title-fragm-field'; -const topicDescriptionField = 'textarea.e2e-test-new-topic-description-field'; -const createTopicButton = 'button.e2e-test-confirm-topic-creation-button'; -const saveTopicButton = 'button.e2e-test-save-topic-button'; -const topicMetaTagInput = '.e2e-test-topic-meta-tag-content-field'; - -const addSubtopicButton = 'button.e2e-test-add-subtopic-button'; -const subtopicTitleField = 'input.e2e-test-subtopic-title-field'; -const subtopicUrlFragmentField = - '.e2e-test-create-new-subtopic .e2e-test-url-fragment-field'; -const subtopicDescriptionEditorToggle = 'div.e2e-test-show-schema-editor'; -const createSubtopicButton = '.e2e-test-confirm-subtopic-creation-button'; -const subtopicNameSelector = '.e2e-test-subtopic-name'; -const subtopicReassignHeader = 'div.subtopic-reassign-header'; -const assignSubtopicButton = '.e2e-test-assign-subtopic'; - -const skillsTab = 'a.e2e-test-skills-tab'; -const desktopSkillSelector = '.e2e-test-skill-description'; -const skillDescriptionField = 'input.e2e-test-new-skill-description-field'; -const skillEditorCollapsibleCard = '.e2e-test-skill-editor-collapsible-card'; -const skillReviewMaterialHeader = 'div.e2e-test-open-concept-card'; -const addSkillButton = 'button.e2e-test-add-skill-button'; -const confirmSkillCreationButton = - 'button.e2e-test-confirm-skill-creation-button'; - -const editSkillItemSelector = 'i.e2e-test-skill-item-edit-btn'; -const confirmSkillAssignationButton = - 'button.e2e-test-skill-assign-subtopic-confirm'; - -const addDiagnosticTestSkillButton = - 'button.e2e-test-add-diagnostic-test-skill'; -const diagnosticTestSkillSelector = - 'select.e2e-test-diagnostic-test-skill-selector'; -const desktopSkillQuestionTab = '.e2e-test-questions-tab'; -const mobileSkillQuestionTab = '.e2e-test-mobile-questions-tab'; -const saveChangesMessageInput = 'textarea.e2e-test-commit-message-input'; - -const mobileOptionsSelector = '.e2e-test-mobile-options-base'; -const mobileTopicSelector = 'div.e2e-test-mobile-topic-name a'; -const mobileSkillSelector = 'span.e2e-test-mobile-skill-name'; - -const mobileSaveTopicDropdown = - 'div.navbar-mobile-options .e2e-test-mobile-save-topic-dropdown'; -const mobileSaveTopicButton = - 'div.navbar-mobile-options .e2e-test-mobile-save-topic-button'; - const createNewClassroomModal = '.e2e-test-create-new-classroom-modal'; const createNewClassroomButton = '.e2e-test-add-new-classroom-config'; const newClassroomNameInputField = '.e2e-test-new-classroom-name'; @@ -100,10 +35,6 @@ const newClassroomUrlFragmentInputField = const newClassroomFeedbackRecipientInputField = '.e2e-test-new-classroom-feedback-recipient'; const saveNewClassroomButton = '.e2e-test-create-new-classroom'; -const classroomTileSelector = '.e2e-test-classroom-tile'; - -const editClassroomConfigButton = '.e2e-test-edit-classroom-config-button'; -const closeClassroomConfigButton = '.e2e-cancel-classroom-changes'; const editClassroomCourseDetailsInputField = '.e2e-test-update-classroom-course-details'; const editClassroomTeaserTextInputField = @@ -118,459 +49,14 @@ const classroomThumbnailContainer = const classroomBannerContainer = '.e2e-test-classroom-banner-container .e2e-test-photo-button'; const imageUploaderModal = '.e2e-test-image-uploader-modal'; -const openTopicDropdownButton = '.e2e-test-add-topic-to-classroom-button'; -const topicDropDownFormField = '.e2e-test-classroom-category-dropdown'; -const topicSelector = '.e2e-test-classroom-topic-selector-choice'; + const publishClassroomButton = '.e2e-test-toggle-classroom-publication-status-btn'; const saveClassroomButton = '.e2e-test-save-classroom-config-button'; -const classroomTileNameSpan = '.e2e-test-classroom-tile-name'; -const addTopicFormFieldInput = - '.mat-select-search-input:not(.mat-select-search-hidden)'; -const createNewTopicButton = '.e2e-test-create-topic-button'; -const createNewTopicMobileButton = '.e2e-test-create-topic-mobile-button'; const enableDiagnosticTestButton = '.e2e-test-toggle-diagnostic-test-status-btn'; -const addStoryButton = 'button.e2e-test-create-story-button'; -const storyTitleField = 'input.e2e-test-new-story-title-field'; -const storyUrlFragmentField = - '.e2e-test-create-new-story-url-fragment-field .e2e-test-url-fragment-field'; -const storyDescriptionField = 'textarea.e2e-test-new-story-description-field'; -const createStoryButton = 'button.e2e-test-confirm-story-creation-button'; -const storyPhotoBoxButton = - 'oppia-create-new-story-modal .e2e-test-photo-button'; -const storyMetaTagInput = '.e2e-test-story-meta-tag-content-field'; -const publishStoryButton = 'button.e2e-test-publish-story-button'; -const unpublishStoryButton = 'button.e2e-test-unpublish-story-button'; - -const mobileStoryDropdown = '.e2e-test-story-dropdown'; -const mobileSaveStoryChangesDropdown = - 'div.navbar-mobile-options .e2e-test-mobile-changes-dropdown'; -const mobilePublishStoryButton = - 'div.navbar-mobile-options .e2e-test-mobile-publish-button'; - -const addChapterButton = 'button.e2e-test-add-chapter-button'; - -const saveStoryButton = 'button.e2e-test-save-story-button'; -const mobileSaveStoryChangesButton = - 'div.navbar-mobile-options .e2e-test-mobile-save-changes'; -const newChapterTitleField = 'input.e2e-test-new-chapter-title-field'; -const newChapterExplorationIdField = 'input.e2e-test-chapter-exploration-input'; -const newChapterPhotoBoxButton = - '.e2e-test-chapter-input-thumbnail .e2e-test-photo-button'; -const mobileChapterCollapsibleCard = '.e2e-test-mobile-add-chapter'; -const createChapterButton = 'button.e2e-test-confirm-chapter-creation-button'; - -const insertWorkedExampleButton = '.cke_button__oppiaworkedexample'; -const editWorkedExampleModalQuestionRte = - '.e2e-test-arg-editor-inner-0 .e2e-test-rte'; -const editWorkedExampleModalAnswerRte = - '.e2e-test-arg-editor-inner-1 .e2e-test-rte'; -const rteComponentSaveButton = '.e2e-test-close-rich-text-component-editor'; - -const classroomTopicBoxSelector = '.e2e-test-classroom-topic-box'; -const classroomTopicNameSelector = '.e2e-test-classroom-topic-name'; -const matFormFieldSelector = 'mat-form-field'; -const mobilePublishTopicButton = - 'div.navbar-mobile-options .e2e-test-mobile-publish-topic-button'; -const publishTopicButton = 'button.e2e-test-publish-topic-button'; - -const floatTextField = '.e2e-test-rule-details .e2e-test-float-form-input'; -const solutionFloatTextField = - 'oppia-add-or-update-solution-modal .e2e-test-float-form-input'; -const textStateEditSelector = 'div.e2e-test-state-edit-content'; -const saveContentButton = 'button.e2e-test-save-state-content'; -const addQuestionButton = 'button.e2e-test-create-question-button'; -const createQuestionButton = 'div.e2e-test-create-question'; -const addInteractionButton = 'button.e2e-test-open-add-interaction-modal'; -const interactionNumberInputButton = - 'div.e2e-test-interaction-tile-NumericInput'; -const saveInteractionButton = 'button.e2e-test-save-interaction'; -const responseRuleDropdown = - 'oppia-rule-type-selector.e2e-test-answer-description'; -const equalsRuleButtonText = 'is equal to ...'; -const answersInGroupAreCorrectToggle = - 'input.e2e-test-editor-correctness-toggle'; -const saveResponseButton = 'button.e2e-test-add-new-response'; -const defaultFeedbackTab = 'a.e2e-test-default-response-tab'; -const openOutcomeFeedBackEditor = 'div.e2e-test-open-outcome-feedback-editor'; -const saveOutcomeFeedbackButton = 'button.e2e-test-save-outcome-feedback'; -const openAnswerGroupFeedBackEditor = 'i.e2e-test-open-feedback-editor'; -const addHintButton = 'button.e2e-test-oppia-add-hint-button'; -const saveHintButton = 'button.e2e-test-save-hint'; -const addSolutionButton = 'button.e2e-test-oppia-add-solution-button'; -const answerTypeDropdown = 'select.e2e-test-answer-is-exclusive-select'; -const submitAnswerButton = 'button.e2e-test-submit-answer-button'; -const submitSolutionButton = 'button.e2e-test-submit-solution-button'; -const interactionNameDiv = 'div.oppia-interaction-tile-name'; -const saveQuestionButton = 'button.e2e-test-save-question-button'; - -const subtopicExpandHeaderSelector = '.e2e-test-show-subtopics-list'; -const practiceTabToggle = '.e2e-test-toggle-practice-tab'; - export class CurriculumAdmin extends TopicManager { - /** - * Create a basic algebra question in the skill editor page. - * @param {string} skillName The name of the skill to which the question will be added. - */ - async addBasicAlgebraQuestionToSkill(skillName: string): Promise { - await this.openSkillEditor(skillName); - await this.clickOnElementWithSelector(createQuestionButton); - await this.clickOnElementWithSelector(textStateEditSelector); - await this.expectElementToBeVisible(richTextAreaField); - await this.typeInInputField(richTextAreaField, 'Add 1+2'); - await this.expectElementToBeVisible(`${saveContentButton}:not([disabled])`); - await this.clickOnElementWithSelector(saveContentButton); - - await this.clickOnElementWithSelector(addInteractionButton); - await this.expectElementToBeVisible(interactionNumberInputButton); - - await this.clickOnElementWithSelectorAndText( - interactionNameDiv, - 'Number Input' - ); - - await this.clickOnElementWithSelector(saveInteractionButton); - await this.expectElementToBeVisible( - 'oppia-add-answer-group-modal-component' - ); - await this.clickOnElementWithSelector(responseRuleDropdown); - await this.clickOnElementWithText(equalsRuleButtonText); - await this.typeInInputField(floatTextField, '3'); - await this.clickOnElementWithSelector(answersInGroupAreCorrectToggle); - await this.clickOnElementWithSelector(openAnswerGroupFeedBackEditor); - await this.typeInInputField(richTextAreaField, 'Good job!'); - await this.clickOnElementWithSelector(saveResponseButton); - await this.expectElementToBeVisible(modalDiv, false); - - await this.clickOnElementWithSelector(defaultFeedbackTab); - await this.clickOnElementWithSelector(openOutcomeFeedBackEditor); - await this.clickOnElementWithSelector(richTextAreaField); - await this.typeInInputField(richTextAreaField, 'The answer is 3'); - await this.clickOnElementWithSelector(saveOutcomeFeedbackButton); - - await this.clickOnElementWithSelector(addHintButton); - await this.expectElementToBeVisible(modalDiv); - await this.typeInInputField(richTextAreaField, '3'); - await this.clickOnElementWithSelector(saveHintButton); - await this.expectElementToBeVisible(modalDiv, false); - - await this.clickOnElementWithSelector(addSolutionButton); - await this.expectElementToBeVisible(modalDiv); - await this.expectElementToBeVisible(answerTypeDropdown); - await this.select(answerTypeDropdown, 'The only'); - await this.expectElementToBeVisible(solutionFloatTextField); - await this.typeInInputField(solutionFloatTextField, '3'); - await this.expectElementToBeVisible( - `${submitAnswerButton}:not([disabled])` - ); - await this.clickOnElementWithSelector(submitAnswerButton); - await this.typeInInputField(richTextAreaField, '1+2 is 3'); - await this.expectElementToBeVisible( - `${submitSolutionButton}:not([disabled])` - ); - await this.clickOnElementWithSelector(submitSolutionButton); - await this.expectElementToBeVisible(modalDiv, false); - - await this.clickOnElementWithSelector(saveQuestionButton); - - await this.waitForNetworkIdle(); - await this.expectElementToBeVisible(modalDiv, false); - } - - /** - * Create a chapter for a certain story. - * @param {string} chapterName The name of the chapter to be created. - * @param {string} explorationId The ID of the exploration to be added to the chapter. - */ - async addChapter(chapterName: string, explorationId: string): Promise { - if (this.isViewportAtMobileWidth()) { - await this.waitForStaticAssetsToLoad(); - const addChapterButtonElement = await this.page.$(addChapterButton); - if (!addChapterButtonElement) { - await this.clickOnElementWithSelector(mobileChapterCollapsibleCard); - } - } - await this.expectElementToBeVisible(addChapterButton); - await this.clickOnElementWithSelector(addChapterButton); - await this.typeInInputField(newChapterTitleField, chapterName); - await this.typeInInputField(newChapterExplorationIdField, explorationId); - - await this.clickOnElementWithSelector(newChapterPhotoBoxButton); - await this.uploadFile(curriculumAdminThumbnailImage); - await this.expectElementToBeVisible(`${uploadPhotoButton}:not([disabled])`); - await this.clickOnElementWithSelector(uploadPhotoButton); - - await this.expectElementToBeVisible(photoUploadModal, false); - await this.clickOnElementWithSelector(createChapterButton); - await this.expectElementToBeVisible(modalDiv, false); - showMessage(`Chapter ${chapterName} is created.`); - } - - /** - * Adds a prerequisite topic to a topic in a classroom. - * @param {string} topicName The name of the topic. - * @param {string} prerequisiteTopicName The name of the prerequisite topic. - */ - async addPrerequisiteTopicForATopicInClassroom( - topicName: string, - prerequisiteTopicName: string - ): Promise { - const topicBox = await this.expectClassroomToContainTopic(topicName); - - const prerequisiteInputElement = await this.getElementInParent( - matFormFieldSelector, - topicBox - ); - if (!prerequisiteInputElement) { - throw new Error('Prerequisite input element not found'); - } - await this.clickOnElement(prerequisiteInputElement); - - await this.selectMatOption(prerequisiteTopicName); - await this.expectMatChipToBeVisible(prerequisiteTopicName); - } - - /** - * Add a skill for diagnostic test and then publish the topic. - * Adding a skill to diagnostic test is necessary for publishing the topic. - * @param {string} skillName The name of the skill to be added to the diagnostic test. - * @param {string} topicName The name of the topic to which the skill will be added. - */ - async addSkillToDiagnosticTest( - skillName: string, - topicName?: string - ): Promise { - if (topicName) { - await this.openTopicEditor(topicName); - } - await this.clickOnElementWithSelector(addDiagnosticTestSkillButton); - await this.expectElementToBeVisible(diagnosticTestSkillSelector); - await this.clickOnElementWithSelector(diagnosticTestSkillSelector); - - /** - * We select the skill in the dropdown with this method because the event doesn't propagate - * otherwise and no further changes are made to the DOM, even though the option is selected. - */ - await this.page.evaluate( - ({ - optionValue, - selectElemSelector, - }: { - optionValue: string; - selectElemSelector: string; - }) => { - const selectElem = document.querySelector( - selectElemSelector - ) as HTMLSelectElement | null; - if (!selectElem) { - console.error('Select element not found'); - return; - } - - const option = Array.from(selectElem.options).find( - opt => opt.textContent?.trim() === optionValue - ) as HTMLOptionElement | undefined; - if (!option) { - console.error('Option not found'); - return; - } - - option.selected = true; - const event = new Event('change', {bubbles: true}); - selectElem.dispatchEvent(event); - }, - {optionValue: skillName, selectElemSelector: diagnosticTestSkillSelector} - ); - if (!topicName) { - throw new Error('topicName is undefined'); - } - await this.saveTopicDraft(topicName); - } - - /** - * Creates a new story with the given title, URL fragment, and topic name. - * Note: This function only creates a story and does not add any chapters to it. - * @param {string} storyTitle - The title of the story. - * @param {string} storyUrlFragment - The URL fragment of the story. - * @param {string} topicName - The name of the topic. - * @param {string} metaTag - The meta tag of the story. - * @param {string} photoURL - The URL of the photo of the story. - */ - async addStoryToTopic( - storyTitle: string, - storyUrlFragment: string, - topicName: string, - metaTag: string = 'meta', - photoURL: string = curriculumAdminThumbnailImage - ): Promise { - await this.openTopicEditor(topicName); - if (this.isViewportAtMobileWidth()) { - await this.clickOnElementWithSelector(mobileStoryDropdown); - } - await this.clickOnElementWithSelector(addStoryButton); - await this.typeInInputField(storyTitleField, storyTitle); - await this.expectElementToBeVisible(storyUrlFragmentField); - await this.typeInInputField(storyUrlFragmentField, storyUrlFragment); - await this.typeInInputField( - storyDescriptionField, - `Story creation description for ${storyTitle}.` - ); - - await this.clickOnElementWithSelector(storyPhotoBoxButton); - await this.uploadFile(photoURL); - await this.expectElementToBeVisible(`${uploadPhotoButton}:not([disabled])`); - await this.clickOnElementWithSelector(uploadPhotoButton); - - await this.expectElementToBeVisible(photoUploadModal, false); - await this.clickAndWaitForNavigation(createStoryButton, true); - - await this.expectElementToBeVisible(storyMetaTagInput); - await this.page.focus(storyMetaTagInput); - await this.typeInInputField(storyMetaTagInput, metaTag); - await this.page.keyboard.press('Tab'); - await this.saveStoryDraft(); - - const url = new URL(this.page.url()); - const pathSegments = url.pathname.split('/'); - const storyId = pathSegments[pathSegments.length - 1]; - showMessage(`Story ${storyTitle} is created.`); - await this.waitForNetworkIdle(); - - return storyId; - } - - /** - * Function for adding a topic to a classroom. - * @param {string} classroomName - The name of the classroom. - * @param {string} topicName - The name of the topic. - * @param {string[]} prerequisiteTopics - The prerequisite topics of the topic. - */ - async addTopicToClassroom( - classroomName: string, - topicName: string, - prerequisiteTopics: string[] = [] - ): Promise { - await this.navigateToClassroomAdminPage(); - await this.editClassroom(classroomName); - - await this.clickOnElementWithSelector(openTopicDropdownButton); - await this.clickOnElementWithSelector(topicDropDownFormField); - await this.expectElementToBeVisible(addTopicFormFieldInput); - await this.typeInInputField(addTopicFormFieldInput, topicName); - - await this.expectElementToBeVisible(topicSelector); - await this.clickOnElementWithSelectorAndText(topicSelector, topicName); - - await this.expectElementToBeVisible(openTopicDropdownButton); - - await this.waitForNetworkIdle(); // Wait for the topic to appear in the classroom before adding prerequisites. - - // Increased timeout to 60s because addTopicId makes an async API call that can take time. - await this.page.waitForFunction( - ({ - topicBoxSelector, - topicNameSelector, - expectedTopicName, - }: { - topicBoxSelector: string; - topicNameSelector: string; - expectedTopicName: string; - }) => { - const topicBoxElements = document.querySelectorAll(topicBoxSelector); - for (const element of topicBoxElements) { - const topicNameElement = element.querySelector(topicNameSelector); - if (topicNameElement?.textContent?.trim() === expectedTopicName) { - return true; - } - } - return false; - }, - { - topicBoxSelector: classroomTopicBoxSelector, - topicNameSelector: classroomTopicNameSelector, - expectedTopicName: topicName, - }, - {timeout: 60000} - ); - - for (const prerequisiteTopic of prerequisiteTopics) { - await this.addPrerequisiteTopicForATopicInClassroom( - topicName, - prerequisiteTopic - ); - } - - await this.clickOnElementWithSelector(saveClassroomButton); - await this.expectElementToBeVisible(saveClassroomButton, false); - - showMessage(`Added ${topicName} topic to the ${classroomName} classroom.`); - } - - /** - * Assign a skill to a subtopic in the topic editor page. - * @param {string} skillName The name of the skill to be assigned. - * @param {string} subtopicName The name of the subtopic to which the skill will be assigned. - * @param {string} topicName The name of the topic containing the subtopic. - */ - async assignSkillToSubtopicInTopicEditor( - skillName: string, - subtopicName: string, - topicName: string - ): Promise { - await this.openTopicEditor(topicName); - if (this.isViewportAtMobileWidth()) { - await this.clickOnElementWithSelector(subtopicReassignHeader); - } - - await this.expectElementToBeVisible('div.e2e-test-skill-item'); - await this.page.evaluate( - ({ - skillName, - topicName, - editSkillItemSelector, - }: { - skillName: string; - topicName: string; - editSkillItemSelector: string; - }) => { - const skillItemDivs = Array.from( - document.querySelectorAll('div.e2e-test-skill-item') - ); - const element = skillItemDivs.find( - el => el.textContent?.trim() === skillName - ) as HTMLElement; - if (element) { - const assignSkillButton = element.querySelector( - editSkillItemSelector - ) as HTMLElement; - assignSkillButton.click(); - } else { - throw new Error( - `Cannot find skill called "${skillName}" in ${topicName}.` - ); - } - }, - {skillName, topicName, editSkillItemSelector} - ); - - await this.expectElementToBeVisible(assignSubtopicButton); - await this.clickOnElementWithText('Assign to Subtopic'); - - await this.clickOnElementWithSelectorAndText( - subtopicNameSelector, - subtopicName - ); - - await this.expectElementToBeVisible( - `${confirmSkillAssignationButton}:not([disabled])` - ); - await this.clickOnElementWithSelector(confirmSkillAssignationButton); - await this.expectElementToBeVisible(changeSubtopicAssignmentModal, false); - await this.saveTopicDraft(topicName); - } - /** * Creates, updates, and publishes a new classroom with a topic. * @param {string} classroomName - The name of the classroom. @@ -599,99 +85,6 @@ export class CurriculumAdmin extends TopicManager { await this.publishClassroom(classroomName); } - /** - * Create a story, execute chapter creation for - * the story, and then publish the story. - * @param {string} storyTitle - The title of the story. - * @param {string} storyUrlFragment - The URL fragment for the story. - * @param {string} chapterTitle - The title of the chapter to be added to the story. - * @param {string} explorationId - The ID of the exploration to be added to the chapter. - * @param {string} topicName - The name of the topic to which the story will be added (optional). - */ - async createAndPublishStoryWithChapter( - storyTitle: string, - storyUrlFragment: string, - chapterTitle: string, - explorationId: string, - topicName?: string - ): Promise { - if (topicName) { - await this.openTopicEditor(topicName); - } - if (this.isViewportAtMobileWidth()) { - await this.clickOnElementWithSelector(mobileStoryDropdown); - } - await this.clickOnElementWithSelector(addStoryButton); - await this.typeInInputField(storyTitleField, storyTitle); - await this.expectElementToBeVisible(storyUrlFragmentField); - await this.typeInInputField(storyUrlFragmentField, storyUrlFragment); - await this.typeInInputField( - storyDescriptionField, - `Story creation description for ${storyTitle}.` - ); - - await this.clickOnElementWithSelector(storyPhotoBoxButton); - await this.uploadFile(curriculumAdminThumbnailImage); - await this.expectElementToBeVisible(`${uploadPhotoButton}:not([disabled])`); - await this.clickOnElementWithSelector(uploadPhotoButton); - - await this.expectElementToBeVisible(photoUploadModal, false); - await this.clickAndWaitForNavigation(createStoryButton, true); - - await this.expectElementToBeVisible(storyMetaTagInput); - await this.page.focus(storyMetaTagInput); - await this.typeInInputField(storyMetaTagInput, 'meta'); - await this.page.keyboard.press('Tab'); - - await this.addChapter(chapterTitle, explorationId); - - await this.saveStoryDraft(); - if (this.isViewportAtMobileWidth()) { - await this.clickOnElementWithSelector(mobileSaveStoryChangesDropdown); - await this.expectElementToBeVisible(mobilePublishStoryButton); - await this.clickOnElementWithSelector(mobilePublishStoryButton); - } else { - await this.expectElementToBeVisible( - `${publishStoryButton}:not([disabled])` - ); - await this.clickOnElementWithSelector(publishStoryButton); - await this.expectElementToBeVisible(unpublishStoryButton); - } - } - - /** - * Creates and publishes a topic with a subtopic and skill. - * @param {string} topicName - The name of the topic. - * @param {string} subtopicName - The name of the subtopic. - * @param {string} skillName - The name of the skill. - */ - async createAndPublishTopic( - topicName: string, - subtopicName: string, - skillName: string - ): Promise { - await this.createTopic( - topicName, - topicName.toLowerCase().replace(/ /g, '-') - ); - await this.createSubtopicForTopic( - subtopicName, - subtopicName.toLowerCase().replace(/ /g, '-'), - topicName - ); - - await this.createSkillForTopic(skillName, topicName, false); - await this.createQuestionsForSkill(skillName, 3); - await this.assignSkillToSubtopicInTopicEditor( - skillName, - subtopicName, - topicName - ); - await this.addSkillToDiagnosticTest(skillName, topicName); - - await this.publishDraftTopic(topicName); - } - /** * Function for creating a new classroom. * @param {string} classroomName - The name of the classroom. @@ -717,179 +110,6 @@ export class CurriculumAdmin extends TopicManager { showMessage(`Created ${classroomName} classroom.`); } - /** - * Create a skill for a particular topic. - * @param {string} description - The description of the skill to be created. - * @param {string} topicName - The name of the topic for which the skill is - * to be created. - * @param {boolean} addWorkedExample - True if the skill should have a - * WorkedExample, false otherwise. - */ - async createSkillForTopic( - description: string, - topicName: string, - addWorkedExample: boolean = false - ): Promise { - await this.openTopicEditor(topicName); - if (this.isViewportAtMobileWidth()) { - await this.clickOnElementWithSelector(subtopicReassignHeader); - } - await this.expectElementToBeVisible(addSkillButton); - await this.clickOnElementWithSelector(addSkillButton); - await this.fillSkillInfoAndSubmit( - description, - `Review material text content for ${description}.`, - addWorkedExample - ); - } - - /** - * Create a subtopic as a curriculum admin. - * @param {string} title - The title of the Subtopic. - * @param {string} urlFragment - The url fragment of the Subtopic. - * @param {string} topicName - The name of the Topic which storing the new Subtopic. - */ - async createSubtopicForTopic( - title: string, - urlFragment: string, - topicName: string - ): Promise { - await this.openTopicEditor(topicName); - if (this.isViewportAtMobileWidth()) { - await this.clickOnElementWithSelector(subtopicReassignHeader); - } - await this.clickOnElementWithSelector(addSubtopicButton); - await this.typeInInputField(subtopicTitleField, title); - await this.expectElementToBeVisible(subtopicUrlFragmentField); - await this.typeInInputField(subtopicUrlFragmentField, urlFragment); - - await this.clickOnElementWithSelector(subtopicDescriptionEditorToggle); - await this.expectElementToBeVisible(richTextAreaField); - await this.typeInInputField( - richTextAreaField, - `Subtopic creation description text for ${title}` - ); - - await this.clickOnElementWithSelector(subtopicPhotoBoxButton); - await this.expectElementToBeVisible(photoUploadModal); - await this.uploadFile(curriculumAdminThumbnailImage); - await this.expectElementToBeVisible(`${uploadPhotoButton}:not([disabled])`); - await this.clickOnElementWithSelector(uploadPhotoButton); - - await this.expectElementToBeVisible(photoUploadModal, false); - await this.clickOnElementWithSelector(createSubtopicButton); - await this.saveTopicDraft(topicName); - showMessage(`Subtopic ${title} is created.`); - } - - /** - * Create a topic in the topics-and-skills dashboard. - * @param {string} name - The name of the topic. - * @param {string} urlFragment - The URL fragment for the topic. - * @returns {Promise} - A promise that resolves to the ID of the created topic. - */ - async createTopic(name: string, urlFragment: string): Promise { - await this.navigateToTopicAndSkillsDashboardPage(); - let TopicSelectorElement = null; - try { - TopicSelectorElement = await this.expectElementToBeAttachedInDOM( - desktopTopicSelector, - this.page, - 10000 - ); - } catch { - // Element didn't appear in 10 seconds — treat as not present. - } - - if (!TopicSelectorElement || !this.isViewportAtMobileWidth()) { - await this.clickOnElementWithSelector(createNewTopicButton); - } else { - await this.clickOnElementWithSelector(createNewTopicMobileButton); - } - - await this.typeInInputField(topicNameField, name); - await this.expectElementToBeVisible(topicUrlFragmentField); - await this.typeInInputField(topicUrlFragmentField, urlFragment); - await this.typeInInputField(topicWebFragmentField, name); - await this.typeInInputField( - topicDescriptionField, - `Topic creation description test for ${name}.` - ); - - await this.clickOnElementWithSelector(photoBoxButton); - await this.expectElementToBeVisible(photoUploadModal); - await this.uploadFile(curriculumAdminThumbnailImage); - await this.expectElementToBeVisible(`${uploadPhotoButton}:not([disabled])`); - await this.clickOnElementWithSelector(uploadPhotoButton); - await this.expectElementToBeVisible(photoUploadModal, false); - await this.clickOnElementWithSelector(createTopicButton); - - await this.expectElementToBeAttachedInDOM('.e2e-test-topics-table'); - await this.openTopicEditor(name); - await this.expectElementToBeVisible(topicMetaTagInput); - await this.page.focus(topicMetaTagInput); - await this.typeInInputField(topicMetaTagInput, 'meta'); - await this.page.keyboard.press('Tab'); - await this.saveTopicDraft(name); - const topicUrl = this.page.url(); - let topicId = topicUrl - .replace(/^.*\/topic_editor\//, '') - .replace(/#\/.*/, ''); - - return topicId; - } - - /** - * Add any number of questions to a particular skill. - * @param {string} skillName The name of the skill to which the questions will be added. - * @param {number} questionCount The number of questions to be added. - */ - async createQuestionsForSkill( - skillName: string, - questionCount: number - ): Promise { - for (let i = 0; i < questionCount; i++) { - await this.addBasicAlgebraQuestionToSkill(skillName); - } - } - - /** - * Function for opening the classroom tile in edit mode. - * @param {string} classroomName - The name of the classroom to be edited. - */ - async editClassroom(classroomName: string): Promise { - await this.navigateToClassroomAdminPage(); - await this.expectElementToBeVisible(classroomTileSelector); - const classroomTiles = await this.page.$$(classroomTileSelector); - - if (classroomTiles.length === 0) { - throw new Error('No classrooms are present.'); - } - - let foundClassroom = false; - - for (let i = 0; i < classroomTiles.length; i++) { - const currentClassroomName = await classroomTiles[i].$eval( - classroomTileNameSpan, - element => (element as HTMLSpanElement).innerText.trim() - ); - - if (currentClassroomName === classroomName) { - await this.clickOnElement(classroomTiles[i]); - await this.expectElementToBeVisible(editClassroomConfigButton); - await this.clickOnElementWithSelector(editClassroomConfigButton); - await this.expectElementToBeVisible(closeClassroomConfigButton); - - foundClassroom = true; - break; - } - } - - if (!foundClassroom) { - throw new Error(`${classroomName} classroom does not exist.`); - } - } - /** * Enables diagnostic test for a classroom. * @param {string} classroomName - The name of the classroom. @@ -904,86 +124,6 @@ export class CurriculumAdmin extends TopicManager { showMessage(`Enabled diagnostic test for ${classroomName} classroom.`); } - /** - * Checks if the classroom contains a topic with the given name. - * @param {string} topicName The name of the topic to check for. - * @returns {Promise>} A promise that resolves to the ElementHandle - * of the topic box if found, or throws an error if not found. - */ - async expectClassroomToContainTopic( - topicName: string - ): Promise> { - await this.expectElementToBeVisible(classroomTopicBoxSelector); - - const topicBoxElements = await this.page.$$(classroomTopicBoxSelector); - let topicBoxElement: ElementHandle | null = null; - - for (const element of topicBoxElements) { - const topicBoxElementText = await element.$eval( - classroomTopicNameSelector, - element => element.textContent?.trim() - ); - if (topicBoxElementText === topicName) { - topicBoxElement = element; - break; - } - } - - if (!topicBoxElement) { - throw new Error(`Topic ${topicName} not found in classroom.`); - } - - return topicBoxElement; - } - - /** - * Fills the skill info and submits the form. - * @param {string} skillName The name of the skill. - * @param {string} reviewMaterial The review material text content. - * @param {boolean} addWorkedExample Whether to add a worked example. - */ - async fillSkillInfoAndSubmit( - skillName: string, - reviewMaterial: string, - addWorkedExample: boolean = false - ): Promise { - await this.typeInInputField(skillDescriptionField, skillName); - await this.expectElementToBeVisible(skillReviewMaterialHeader); - await this.clickOnElementWithSelector(skillReviewMaterialHeader); - await this.clickOnElementWithSelector(richTextAreaField); - await this.typeInInputField(richTextAreaField, reviewMaterial); - if (addWorkedExample) { - await this.clickOnElementWithSelector(insertWorkedExampleButton); - await this.expectElementToBeVisible(editWorkedExampleModalQuestionRte); - await this.clearAllTextFrom(editWorkedExampleModalQuestionRte); - await this.typeInInputField( - editWorkedExampleModalQuestionRte, - 'Type the number one' - ); - await this.expectElementToBeVisible(editWorkedExampleModalAnswerRte); - await this.clearAllTextFrom(editWorkedExampleModalAnswerRte); - await this.waitForElementToStabilize(editWorkedExampleModalAnswerRte); - await this.typeInInputField(editWorkedExampleModalAnswerRte, '1'); - await this.clickOnElementWithSelector(rteComponentSaveButton); - } - await this.expectElementToBeVisible( - `${confirmSkillCreationButton}:not([disabled])` - ); - await this.clickOnElementWithSelector(confirmSkillCreationButton); - await this.waitForNetworkIdle(); - await this.expectElementToBeVisible(confirmSkillCreationButton, false); - await this.page.bringToFront(); - } - - /** - * Function for navigating to the classroom admin page. - */ - async navigateToClassroomAdminPage(): Promise { - await this.page.bringToFront(); - await this.waitForNetworkIdle(); - await this.goto(classroomAdminUrl); - } - /** * Function to navigate to exploration editor * @param {string | null} explorationId - ID of the exploration @@ -999,89 +139,6 @@ export class CurriculumAdmin extends TopicManager { showMessage('Navigation to exploration editor is successful.'); } - /** - * Navigate to the question editor tab present in the skills tab. - */ - async navigateToSkillQuestionEditorTab(): Promise { - const isMobileWidth = this.isViewportAtMobileWidth(); - const skillQuestionTab = isMobileWidth - ? mobileSkillQuestionTab - : desktopSkillQuestionTab; - - if (isMobileWidth) { - await this.page.waitForFunction(() => - window.location.href.includes('skill_editor') - ); - const currentUrl = new URL(this.page.url()); - const hashParts = currentUrl.hash.split('/'); - - if (hashParts.length > 1) { - hashParts[1] = 'questions'; - } else { - hashParts.push('questions'); - } - currentUrl.hash = hashParts.join('/'); - await this.goto(currentUrl.toString()); - // Changing only the URL hash triggers a same-document navigation in - // the browser (no reload, no re-run of the app's bootstrap code), - // so the app never re-evaluates the hash to switch tabs. A full - // reload is required to force the app to re-initialize and pick up - // the 'questions' tab from the updated hash. - await this.reloadPage(); - } else { - await this.expectElementToBeVisible(skillQuestionTab); - await this.clickAndWaitForNavigation(skillQuestionTab, true); - } - await this.expectElementToBeVisible(addQuestionButton); - } - - /** - * Navigate to the topic and skills dashboard page. - */ - async navigateToTopicAndSkillsDashboardPage(): Promise { - await this.page.bringToFront(); - await this.waitForNetworkIdle(); - await this.goto(topicAndSkillsDashboardUrl); - } - - /** - * Open the skill editor page for a skill. - * @param {string} skillName - The name of the skill to be opened in the editor. - */ - async openSkillEditor(skillName: string): Promise { - const skillSelector = this.isViewportAtMobileWidth() - ? mobileSkillSelector - : desktopSkillSelector; - await this.page.bringToFront(); - await this.navigateToTopicAndSkillsDashboardPage(); - await this.clickOnElementWithSelector(skillsTab); - await this.expectElementToBeVisible(skillSelector); - await this.clickOnElementWithSelectorAndText(skillSelector, skillName); - await this.expectElementToBeVisible(skillEditorCollapsibleCard); - - expect(this.page.url()).toContain('/skill_editor/'); - } - - /** - * Open the topic editor page for a topic. - * @param {string} topicName - The name of the topic to be opened in the editor. - */ - async openTopicEditor(topicName: string): Promise { - const topicNameSelector = this.isViewportAtMobileWidth() - ? mobileTopicSelector - : desktopTopicSelector; - await this.navigateToTopicAndSkillsDashboardPage(); - await this.clickOnElementWithSelector(topicsTab); - await this.expectElementToBeVisible(topicNameSelector); - - await Promise.all([ - this.clickOnElementWithSelectorAndText(topicNameSelector, topicName), - this.page.waitForNavigation(), - ]); - - expect(this.page.url()).toContain('/topic_editor/'); - } - /** * Function for publishing a classroom. * @param {string} classroomName - The name of the classroom. @@ -1096,152 +153,6 @@ export class CurriculumAdmin extends TopicManager { showMessage(`Published ${classroomName} classroom.`); } - /** - * Publishes a topic draft. - * @param {string} topicName - Optional. If not provided, the topic editor will be opened. - * - * TODO(#22539): This function has a duplicate in topic-manager.ts. - * To avoid unexpected behavior, ensure that any modifications here are also - * made in topic-manager.ts. - */ - async publishDraftTopic(topicName: string): Promise { - await this.openTopicEditor(topicName); - if (this.isViewportAtMobileWidth()) { - await this.clickOnElementWithSelector(mobileOptionsSelector); - await this.clickOnElementWithSelector(mobileSaveTopicDropdown); - await this.expectElementToBeVisible(mobilePublishTopicButton); - await this.clickOnElementWithSelector(mobilePublishTopicButton); - await this.expectElementToBeVisible(mobilePublishTopicButton, false); - } else { - await this.clickOnElementWithSelector(publishTopicButton); - - await this.expectElementToBeVisible(publishTopicButton, false); - } - } - - /** - * Publish a story. - */ - async publishStoryDraft(): Promise { - if (this.isViewportAtMobileWidth()) { - await this.expectElementToBeVisible(mobileSaveStoryChangesDropdown); - await this.clickOnElementWithSelector(mobileSaveStoryChangesDropdown); - await this.expectElementToBeVisible(mobilePublishStoryButton); - await this.clickOnElementWithSelector(mobilePublishStoryButton); - - await this.page.waitForFunction((selector: string) => { - const element = document.querySelector(selector); - return element?.textContent?.trim() === 'Unpublish Story'; - }, mobilePublishStoryButton); - } else { - await this.expectElementToBeVisible( - `${publishStoryButton}:not([disabled])` - ); - await this.clickOnElementWithSelector(publishStoryButton); - await this.expectElementToBeVisible(unpublishStoryButton); - } - } - - /** - * Save a story. - */ - async saveStoryDraft(): Promise { - if (this.isViewportAtMobileWidth()) { - const isMobileSaveButtonVisible = await this.isElementVisible( - mobileSaveStoryChangesButton - ); - if (!isMobileSaveButtonVisible) { - await this.clickOnElementWithSelector(mobileOptionsSelector); - } - await this.expectElementToBeVisible(mobileSaveStoryChangesButton); - await this.clickOnElementWithSelector(mobileSaveStoryChangesButton); - } else { - await this.expectElementToBeVisible(saveStoryButton); - await this.clickOnElementWithSelector(saveStoryButton); - } - await this.typeInInputField( - saveChangesMessageInput, - 'Test saving story as curriculum admin.' - ); - await this.expectElementToBeVisible( - `${closeSaveModalButton}:not([disabled])` - ); - await this.clickOnElementWithSelector(closeSaveModalButton); - await this.expectElementToBeVisible(modalDiv, false); - } - - /** - * Save a topic as a curriculum admin. - * @param {string} topicName - The name of the Topic whose draft is to be saved. - */ - async saveTopicDraft(topicName?: string): Promise { - await this.expectElementToBeVisible(modalDiv, false); - if (this.isViewportAtMobileWidth()) { - await this.clickOnElementWithSelector(mobileOptionsSelector); - await this.clickOnElementWithSelector(mobileSaveTopicButton); - await this.expectElementToBeVisible('oppia-topic-editor-save-modal'); - await this.typeInInputField( - saveChangesMessageInput, - 'Test saving topic as curriculum admin.' - ); - await this.expectElementToBeVisible( - `${closeSaveModalButton}:not([disabled])` - ); - await this.clickOnElementWithSelector(closeSaveModalButton); - await this.expectElementToBeVisible( - 'oppia-topic-editor-save-modal', - false - ); - if (topicName) { - await this.openTopicEditor(topicName); - } - } else { - await this.clickOnElementWithSelector(saveTopicButton); - - await this.expectElementToBeVisible(modalDiv); - await this.typeInInputField( - saveChangesMessageInput, - 'Test saving topic as curriculum admin.' - ); - await this.expectElementToBeVisible( - `${closeSaveModalButton}:not([disabled])` - ); - await this.clickOnElementWithSelector(closeSaveModalButton); - await this.expectElementToBeVisible(modalDiv, false); - } - } - - /** - * Toggles the "Show practice tab to learners" in Topic Editor. - */ - async togglePracticeTabCheckbox(): Promise { - if (this.isViewportAtMobileWidth()) { - await this.expectElementToBeVisible(subtopicExpandHeaderSelector); - await this.clickOnElementWithSelector(subtopicExpandHeaderSelector); - } - try { - await this.page.waitForSelector(practiceTabToggle); - const practiceTabToggleElement = await this.page.$(practiceTabToggle); - if (!practiceTabToggleElement) { - throw new Error('Practice tab toggle not found.'); - } - await this.waitForElementToBeClickable(practiceTabToggleElement); - await practiceTabToggleElement.click(); - - await this.page.waitForFunction( - (selector: string) => { - const element = document.querySelector(selector); - return (element as HTMLInputElement).checked === true; - }, - practiceTabToggle, - {timeout: 60000} - ); - } catch (error) { - console.error(error instanceof Error ? error.stack : error); - throw error; - } - } - /** * Function for updating a classroom. * @param {string} classroomName - The name of the classroom. diff --git a/core/tests/playwright-acceptance-tests/utilities/user/exploration-editor.ts b/core/tests/playwright-acceptance-tests/utilities/user/exploration-editor.ts index 97d1d30e2d14b..79cd3087a4008 100644 --- a/core/tests/playwright-acceptance-tests/utilities/user/exploration-editor.ts +++ b/core/tests/playwright-acceptance-tests/utilities/user/exploration-editor.ts @@ -20,10 +20,11 @@ import {Page, ElementHandle} from '@playwright/test'; import {BaseUser} from '../common/playwright-utils'; import testConstants from '../common/test-constants'; import {showMessage} from '../common/show-message'; -import {ExplorationEditorModal} from '../common/exploration-editor'; +import {ExplorationEditorUtils} from '../common/exploration-editor-utils'; +import {RTEEditor} from '../common/rte-editor'; import * as fs from 'fs'; import * as path from 'path'; -import {RTEEditor} from '../common/rte-editor'; +import {StateEditorUtils} from '../common/state-editor-utils'; const creatorDashboardPage = testConstants.URLs.CreatorDashboard; const baseUrl = testConstants.URLs.BaseURL; @@ -115,18 +116,6 @@ const addNewResponseButton = 'button.e2e-test-add-new-response'; const responseModalHeaderSelector = '.e2e-test-add-response-modal-header'; const addAnotherResponseButton = 'button.e2e-test-add-another-response'; -const defaultFeedbackTab = 'a.e2e-test-default-response-tab'; -const openOutcomeFeedBackEditor = 'div.e2e-test-open-outcome-feedback-editor'; -const saveOutcomeFeedbackButton = 'button.e2e-test-save-outcome-feedback'; -const destinationSelectorDropdown = '.e2e-test-destination-selector-dropdown'; -const destinationWhenStuckSelectorDropdown = - '.e2e-test-destination-when-stuck-selector-dropdown'; -const saveDestinationButtonSelector = '.e2e-test-save-outcome-dest'; -const saveStuckDestinationButtonSelector = '.e2e-test-save-stuck-destination'; -const addDestinationStateWhenStuckInput = '.protractor-test-add-state-input'; -const outcomeDestWhenStuckSelector = - '.protractor-test-open-outcome-dest-if-stuck-editor'; - const mobileNavbarPane = '.oppia-exploration-editor-tabs-dropdown'; const mobileTranslationTabButton = '.e2e-test-mobile-translation-tab'; const mainTabButton = '.e2e-test-main-tab'; @@ -175,9 +164,6 @@ const historyTableIndex = '.e2e-test-history-table-index'; const historyListOptions = '.e2e-test-history-table-option'; const downloadExplorationButton = 'a.dropdown-item.e2e-test-download-exploration'; -const nextCardButton = '.e2e-test-next-card-button'; -const nextCardArrowButton = '.e2e-test-next-button'; -const previousCardButton = '.e2e-test-back-button'; // Common Selectors. const commonModalTitleSelector = '.e2e-test-modal-header'; @@ -618,21 +604,11 @@ export class ExplorationEditor extends BaseUser { * Function to navigate to the next card in the preview tab. * @param {boolean} skipVerification - Whether to skip verification of the card content. */ - async continueToNextCard(skipVerification: boolean = false): Promise { - try { - await this.clickOnElementWithSelector(nextCardButton); - } catch (error) { - if (error instanceof Error && error.message.includes('Timeout')) { - await this.clickOnElementWithSelector(nextCardArrowButton); - } else { - throw error; - } - } - - if (skipVerification) { - return; - } - await this.expectElementToBeVisible(previousCardButton); + async continueToNextCardAsExplorationEditor( + skipVerification: boolean = false + ): Promise { + const explorationPlayerUtils = new ExplorationEditorUtils(this); + await explorationPlayerUtils.continueToNextCard(skipVerification); } /** @@ -796,13 +772,10 @@ export class ExplorationEditor extends BaseUser { * @param {boolean} failIfMissing - Whether to fail if the welcome modal is not found. */ async dismissWelcomeModal(failIfMissing: boolean = true): Promise { - const explorationEditor = new ExplorationEditorModal(this); + const explorationEditor = new ExplorationEditorUtils(this); await explorationEditor.dismissWelcomeModal(failIfMissing); } - // TODO(#22539): This function has a duplicate in exploration-editor.ts. - // To avoid unexpected behavior, ensure that any modifications here are also - // made in editDefaultResponseFeedbackInQuestionEditorPage() in question-submitter.ts. /** * Function to add feedback for default responses of a state interaction. * @param {string} defaultResponseFeedback - The feedback for the default responses. @@ -814,36 +787,12 @@ export class ExplorationEditor extends BaseUser { directToCard?: string, directToCardWhenStuck?: string ): Promise { - await this.expectElementToBeVisible(defaultFeedbackTab); - await this.clickOnElementWithSelector(defaultFeedbackTab); - - if (defaultResponseFeedback) { - await this.updateDefaultResponseFeedbackInExplorationEditorPage( - defaultResponseFeedback - ); - } - - if (directToCard) { - await this.clickOnElementWithSelector(openOutcomeDestButton); - await this.select(destinationSelectorDropdown, directToCard); - await this.clickOnElementWithSelector(saveDestinationButtonSelector); - await this.expectElementToBeVisible(saveDestinationButtonSelector, false); - } - - if (directToCardWhenStuck) { - await this.clickOnElementWithSelector(outcomeDestWhenStuckSelector); - // The '4: /' value is used to select the 'a new card called' option in the dropdown. - await this.select(destinationWhenStuckSelectorDropdown, '4: /'); - await this.typeInInputField( - addDestinationStateWhenStuckInput, - directToCardWhenStuck - ); - await this.clickOnElementWithSelector(saveStuckDestinationButtonSelector); - await this.expectElementToBeVisible( - saveStuckDestinationButtonSelector, - false - ); - } + const stateEditorUtils = new StateEditorUtils(this); + await stateEditorUtils.editDefaultResponseFeedback( + defaultResponseFeedback, + directToCard, + directToCardWhenStuck + ); } /** @@ -1489,25 +1438,6 @@ export class ExplorationEditor extends BaseUser { } } - /** - * Function to update the default response feedback for a state interaction. - * @param {string} defaultResponseFeedback - The feedback for the default responses. - */ - async updateDefaultResponseFeedbackInExplorationEditorPage( - defaultResponseFeedback: string - ): Promise { - await this.expectElementToBeVisible(openOutcomeFeedBackEditor); - await this.clickOnElementWithSelector(openOutcomeFeedBackEditor); - await this.clickOnElementWithSelector(stateContentInputField); - await this.typeInInputField( - stateContentInputField, - defaultResponseFeedback - ); - await this.clickOnElementWithSelector(saveOutcomeFeedbackButton); - - await this.expectElementToBeVisible(saveOutcomeDestButton, false); - } - /** * Function to display the Oppia responses section. */ diff --git a/core/tests/playwright-acceptance-tests/utilities/user/logged-in-user.ts b/core/tests/playwright-acceptance-tests/utilities/user/logged-in-user.ts index d2f692b10b294..788e84eb7f6e5 100644 --- a/core/tests/playwright-acceptance-tests/utilities/user/logged-in-user.ts +++ b/core/tests/playwright-acceptance-tests/utilities/user/logged-in-user.ts @@ -20,6 +20,7 @@ import {Page, expect, ElementHandle} from '@playwright/test'; import {BaseUser} from '../common/playwright-utils'; import testConstants from '../common/test-constants'; import {showMessage} from '../common/show-message'; +import {NavigationUtils} from '../common/navigation-utils'; const baseUrl = testConstants.URLs.BaseURL; const contributorDashboardAdminUrl = @@ -27,12 +28,9 @@ const contributorDashboardAdminUrl = const learnerDashboardUrl = testConstants.URLs.LearnerDashboard; const profilePageUrlPrefix = testConstants.URLs.ProfilePagePrefix; const loginPageUrl = testConstants.URLs.Login; -const moderatorPageUrl = testConstants.URLs.ModeratorPage; -const releaseCoordinatorPageUrl = testConstants.URLs.ReleaseCoordinator; const signUpEmailField = testConstants.SignInDetails.inputField; const siteAdminPageUrl = testConstants.URLs.AdminPage; const splashPageUrl = testConstants.URLs.splash; -const topicsAndSkillsDashboardUrl = testConstants.URLs.TopicAndSkillsDashboard; // Auth Pages selectors. const loginPage = '.e2e-test-login-page'; @@ -741,18 +739,9 @@ export class LoggedInUser extends BaseUser { * Verifies that the current page URL includes the expected page pathname. * @param {string} expectedPage - The expected page pathname (e.g., 'learner-dashboard'). */ - async expectToBeOnPage(expectedPage: string): Promise { - await this.waitForStaticAssetsToLoad(); - const url = this.page.url(); - - // Replace spaces in the expectedPage with hyphens. - const expectedPageInUrl = expectedPage.replace(/\s+/g, '-'); - - if (!url.toLowerCase().includes(expectedPageInUrl.toLowerCase())) { - throw new Error( - `Expected to be on page ${expectedPage}, but found ${url}` - ); - } + async expectToBeOnPageAsLoggedInUser(expectedPage: string): Promise { + const navigationUtils = new NavigationUtils(this); + await navigationUtils.expectToBeOnPage(expectedPage); } /** @@ -893,11 +882,13 @@ export class LoggedInUser extends BaseUser { /** * Navigates to the learner dashboard. + * @param {boolean} verifyUrl - Whether to verify the URL after navigation. Defaults to true. */ - async navigateToLearnerDashboard(): Promise { - await this.goto(learnerDashboardUrl); - await this.waitForPageToFullyLoad(); - await this.expectElementToBeAttachedInDOM(homeTabSectionInLearnerDashboard); + async navigateToLearnerDashboardAsLoggedInUser( + verifyUrl: boolean = true + ): Promise { + const navigationUtils = new NavigationUtils(this); + await navigationUtils.navigateToLearnerDashboard(verifyUrl); } /** @@ -1239,9 +1230,13 @@ export class LoggedInUser extends BaseUser { /** * Navigates to the Moderator page. + * @param {boolean} verifyUrl - Whether to verify the URL after navigation. Defaults to true. */ - async navigateToModeratorPage(): Promise { - await this.goto(moderatorPageUrl); + async navigateToModeratorPageAsLoggedInUser( + verifyUrl: boolean = true + ): Promise { + const navigationUtils = new NavigationUtils(this); + await navigationUtils.navigateToModeratorPage(verifyUrl); } /** @@ -1260,8 +1255,11 @@ export class LoggedInUser extends BaseUser { /** * Navigates to the Release Coordinator page. */ - async navigateToReleaseCoordinatorPage(): Promise { - await this.goto(releaseCoordinatorPageUrl); + async navigateToReleaseCoordinatorPageAsLoggedInUser( + verifyUrl: boolean = true + ): Promise { + const navigationUtils = new NavigationUtils(this); + await navigationUtils.navigateToReleaseCoordinatorPage(); } /** @@ -1289,8 +1287,9 @@ export class LoggedInUser extends BaseUser { /** * Navigates to the Topics and Skills Dashboard page. */ - async navigateToTopicsAndSkillsDashboardPage(): Promise { - await this.goto(topicsAndSkillsDashboardUrl); + async navigateToTopicsAndSkillsDashboardPageAsLoggedInUser(): Promise { + const navigationUtils = new NavigationUtils(this); + await navigationUtils.navigateToTopicsAndSkillsDashboardPage(); } /** @@ -1980,22 +1979,22 @@ export class LoggedInUser extends BaseUser { * Navigates to the splash page. * @param {string} expectedURL - The expected URL after navigation. Defaults to `${baseUrl}/`. */ - async navigateToSplashPage( + async navigateToSplashPageAsLoggedInUser( expectedURL: string = learnerDashboardUrl ): Promise { - // We explicitly check for expected URL instead of verifying it through - // BaseUser.goto as /splash redirects user to a different page. - await this.goto(splashPageUrl, false); - - expect(this.page.url()).toBe(expectedURL); + const navigationUtils = new NavigationUtils(this); + await navigationUtils.navigateToSplashPage(expectedURL); } /** * Navigates to the exploration page and starts playing the exploration. * @param {string} explorationId - The ID of the exploration to play. */ - async playExploration(explorationId: string | null): Promise { - await this.goto(`${baseUrl}/explore/${explorationId as string}`); + async playExplorationAsLoggedInUser( + explorationId: string | null + ): Promise { + const navigationUtils = new NavigationUtils(this); + await navigationUtils.playExploration(baseUrl, explorationId); } /** diff --git a/core/tests/playwright-acceptance-tests/utilities/user/logged-out-user.ts b/core/tests/playwright-acceptance-tests/utilities/user/logged-out-user.ts index 8e8ccfec84dea..1692345d5ecde 100644 --- a/core/tests/playwright-acceptance-tests/utilities/user/logged-out-user.ts +++ b/core/tests/playwright-acceptance-tests/utilities/user/logged-out-user.ts @@ -21,13 +21,14 @@ import {BaseUser} from '../common/playwright-utils'; import {showMessage} from '../common/show-message'; import testConstants from '../common/test-constants'; import isElementClickable from '../../functions/is-element-clickable'; +import {NavigationUtils} from '../common/navigation-utils'; +import {ExplorationEditorUtils} from '../common/exploration-editor-utils'; const aboutUrl = testConstants.URLs.About; const baseUrl = testConstants.URLs.BaseURL; const classroomsPageUrl = testConstants.URLs.ClassroomsPage; const communityLibraryUrl = testConstants.URLs.CommunityLibrary; const homeUrl = testConstants.URLs.Home; -const splashPageUrl = testConstants.URLs.splash; const LABEL_FOR_SUBMIT_BUTTON = 'Submit and start contributing'; const signUpUsernameInputField = 'input.e2e-test-username-input'; @@ -572,39 +573,13 @@ export class LoggedOutUser extends BaseUser { /** * Function to navigate to the next card in the preview tab. + * @param {boolean} skipVerification - Whether to skip verification of the card content. */ - async continueToNextCard(): Promise { - const currentCardContentSelector = `${stateConversationContent} p`; - await this.expectElementToBeVisible(currentCardContentSelector); - const currentCardContent = await this.page.$eval( - currentCardContentSelector, - el => el.textContent - ); - try { - await this.expectElementToBeVisible( - nextCardButton, - true, - this.page, - 7000 - ); - await this.clickOnElementWithSelector(nextCardButton); - } catch (error) { - if (error instanceof Error && error.message.includes('Timeout')) { - await this.clickOnElementWithSelector(nextCardArrowButton); - } else { - throw error; - } - } - - // Wait until card content changes. - await this.page.waitForFunction( - ({selector, value}: {selector: string; value: string}) => { - const element = document.querySelector(selector); - const text = element?.textContent?.trim(); - return !!text && text !== value?.trim(); - }, - {selector: currentCardContentSelector, value: currentCardContent} - ); + async continueToNextCardAsLoggedOutUser( + skipVerification: boolean = false + ): Promise { + const explorationPlayerUtils = new ExplorationEditorUtils(this); + await explorationPlayerUtils.continueToNextCard(skipVerification); } /** @@ -1688,18 +1663,9 @@ export class LoggedOutUser extends BaseUser { /** * Verifies that the current page URL includes the expected page pathname. */ - async expectToBeOnPage(expectedPage: string): Promise { - await this.waitForStaticAssetsToLoad(); - const url = this.page.url(); - - // Replace spaces in the expectedPage with hyphens. - const expectedPageInUrl = expectedPage.replace(/\s+/g, '-'); - - if (!url.includes(expectedPageInUrl)) { - throw new Error( - `Expected to be on page ${expectedPage}, but found ${url}` - ); - } + async expectToBeOnPageAsLoggedOutUser(expectedPage: string): Promise { + const navigationUtils = new NavigationUtils(this); + await navigationUtils.expectToBeOnPage(expectedPage); } /** @@ -2288,14 +2254,11 @@ export class LoggedOutUser extends BaseUser { * Navigates to the splash page. * @param {string} expectedURL - The expected URL after navigation. Defaults to `${baseUrl}/`. */ - async navigateToSplashPage( + async navigateToSplashPageAsLoggedOutUser( expectedURL: string = `${baseUrl}/` ): Promise { - // We explicitly check for expected URL instead of verifying it through - // BaseUser.goto as /splash redirects user to a different page. - await this.goto(splashPageUrl, false); - - expect(this.page.url()).toBe(expectedURL); + const navigationUtils = new NavigationUtils(this); + await navigationUtils.navigateToSplashPage(expectedURL); } /** @@ -2429,8 +2392,11 @@ export class LoggedOutUser extends BaseUser { * Navigates to and plays an exploration by its ID. * @param {string | null} explorationId - The ID of the exploration to play. */ - async playExploration(explorationId: string | null): Promise { - await this.goto(`${baseUrl}/explore/${explorationId as string}`); + async playExplorationAsLoggedOutUser( + explorationId: string | null + ): Promise { + const navigationUtils = new NavigationUtils(this); + await navigationUtils.playExploration(baseUrl, explorationId); } /** diff --git a/core/tests/playwright-acceptance-tests/utilities/user/topic-manager.ts b/core/tests/playwright-acceptance-tests/utilities/user/topic-manager.ts index a5395bf5ff92b..237af334c94d9 100644 --- a/core/tests/playwright-acceptance-tests/utilities/user/topic-manager.ts +++ b/core/tests/playwright-acceptance-tests/utilities/user/topic-manager.ts @@ -15,10 +15,1080 @@ /** * @fileoverview Topic manager utility file. */ -import {Page} from '@playwright/test'; +import {Page, ElementHandle, expect} from '@playwright/test'; import {BaseUser} from '../common/playwright-utils'; +import testConstants from '../common/test-constants'; +import {showMessage} from '../common/show-message'; +import {NavigationUtils} from '../common/navigation-utils'; -export class TopicManager extends BaseUser {} +const classroomAdminUrl = testConstants.URLs.ClassroomAdmin; +const curriculumAdminThumbnailImage = + testConstants.data.curriculumAdminThumbnailImage; + +const createQuestionButton = 'div.e2e-test-create-question'; +const textStateEditSelector = 'div.e2e-test-state-edit-content'; +const richTextAreaField = 'div.e2e-test-rte'; +const saveContentButton = 'button.e2e-test-save-state-content'; +const addInteractionButton = 'button.e2e-test-open-add-interaction-modal'; +const interactionNumberInputButton = + 'div.e2e-test-interaction-tile-NumericInput'; +const saveInteractionButton = 'button.e2e-test-save-interaction'; +const interactionNameDiv = 'div.oppia-interaction-tile-name'; +const responseRuleDropdown = + 'oppia-rule-type-selector.e2e-test-answer-description'; +const equalsRuleButtonText = 'is equal to ...'; +const answersInGroupAreCorrectToggle = + 'input.e2e-test-editor-correctness-toggle'; +const floatTextField = '.e2e-test-rule-details .e2e-test-float-form-input'; +const openAnswerGroupFeedBackEditor = 'i.e2e-test-open-feedback-editor'; +const saveResponseButton = 'button.e2e-test-add-new-response'; +const defaultFeedbackTab = 'a.e2e-test-default-response-tab'; +const openOutcomeFeedBackEditor = 'div.e2e-test-open-outcome-feedback-editor'; +const saveOutcomeFeedbackButton = 'button.e2e-test-save-outcome-feedback'; +const addHintButton = 'button.e2e-test-oppia-add-hint-button'; +const saveHintButton = 'button.e2e-test-save-hint'; +const addSolutionButton = 'button.e2e-test-oppia-add-solution-button'; +const answerTypeDropdown = 'select.e2e-test-answer-is-exclusive-select'; +const submitAnswerButton = 'button.e2e-test-submit-answer-button'; +const submitSolutionButton = 'button.e2e-test-submit-solution-button'; +const solutionFloatTextField = + 'oppia-add-or-update-solution-modal .e2e-test-float-form-input'; +const saveQuestionButton = 'button.e2e-test-save-question-button'; +const mobileSkillSelector = 'span.e2e-test-mobile-skill-name'; +const desktopSkillSelector = '.e2e-test-skill-description'; +const skillsTab = 'a.e2e-test-skills-tab'; +const skillEditorCollapsibleCard = '.e2e-test-skill-editor-collapsible-card'; + +const modalDiv = 'div.modal-content'; +const mobileSaveTopicButton = + 'div.navbar-mobile-options .e2e-test-mobile-save-topic-button'; +const mobileOptionsSelector = '.e2e-test-mobile-options-base'; +const mobileTopicSelector = 'div.e2e-test-mobile-topic-name a'; +const topicsTab = 'a.e2e-test-topics-tab'; +const closeSaveModalButton = '.e2e-test-close-save-modal-button'; +const desktopTopicSelector = 'a.e2e-test-topic-name'; +const mobileSaveTopicDropdown = + 'div.navbar-mobile-options .e2e-test-mobile-save-topic-dropdown'; +const mobilePublishTopicButton = + 'div.navbar-mobile-options .e2e-test-mobile-publish-topic-button'; +const publishTopicButton = 'button.e2e-test-publish-topic-button'; +const saveChangesMessageInput = 'textarea.e2e-test-commit-message-input'; +const saveTopicButton = 'button.e2e-test-save-topic-button'; +const saveStoryButton = 'button.e2e-test-save-story-button'; +const mobileSaveStoryChangesButton = + 'div.navbar-mobile-options .e2e-test-mobile-save-changes'; +const subtopicExpandHeaderSelector = '.e2e-test-show-subtopics-list'; +const practiceTabToggle = '.e2e-test-toggle-practice-tab'; + +const addDiagnosticTestSkillButton = + 'button.e2e-test-add-diagnostic-test-skill'; +const diagnosticTestSkillSelector = + 'select.e2e-test-diagnostic-test-skill-selector'; +const addQuestionButton = 'button.e2e-test-create-question-button'; +const desktopSkillQuestionTab = '.e2e-test-questions-tab'; +const mobileSkillQuestionTab = '.e2e-test-mobile-questions-tab'; +const mobileStoryDropdown = '.e2e-test-story-dropdown'; +const addStoryButton = 'button.e2e-test-create-story-button'; +const storyTitleField = 'input.e2e-test-new-story-title-field'; +const storyUrlFragmentField = + '.e2e-test-create-new-story-url-fragment-field .e2e-test-url-fragment-field'; +const storyDescriptionField = 'textarea.e2e-test-new-story-description-field'; +const storyPhotoBoxButton = + 'oppia-create-new-story-modal .e2e-test-photo-button'; +const uploadPhotoButton = 'button.e2e-test-photo-upload-submit'; +const photoUploadModal = 'edit-thumbnail-modal'; +const createStoryButton = 'button.e2e-test-confirm-story-creation-button'; +const storyMetaTagInput = '.e2e-test-story-meta-tag-content-field'; +const classroomTopicBoxSelector = '.e2e-test-classroom-topic-box'; +const classroomTopicNameSelector = '.e2e-test-classroom-topic-name'; +const matFormFieldSelector = 'mat-form-field'; +const openTopicDropdownButton = '.e2e-test-add-topic-to-classroom-button'; +const topicDropDownFormField = '.e2e-test-classroom-category-dropdown'; +const topicSelector = '.e2e-test-classroom-topic-selector-choice'; +const addTopicFormFieldInput = + '.mat-select-search-input:not(.mat-select-search-hidden)'; +const classroomTileNameSpan = '.e2e-test-classroom-tile-name'; +const saveClassroomButton = '.e2e-test-save-classroom-config-button'; +const classroomTileSelector = '.e2e-test-classroom-tile'; +const editClassroomConfigButton = '.e2e-test-edit-classroom-config-button'; +const closeClassroomConfigButton = '.e2e-cancel-classroom-changes'; +const subtopicNameSelector = '.e2e-test-subtopic-name'; +const subtopicReassignHeader = 'div.subtopic-reassign-header'; +const assignSubtopicButton = '.e2e-test-assign-subtopic'; +const editSkillItemSelector = 'i.e2e-test-skill-item-edit-btn'; +const confirmSkillAssignationButton = + 'button.e2e-test-skill-assign-subtopic-confirm'; +const changeSubtopicAssignmentModal = + '.oppia-change-subtopic-assignment-modal div.modal-content'; +const mobileSaveStoryChangesDropdown = + 'div.navbar-mobile-options .e2e-test-mobile-changes-dropdown'; +const mobilePublishStoryButton = + 'div.navbar-mobile-options .e2e-test-mobile-publish-button'; +const addChapterButton = 'button.e2e-test-add-chapter-button'; +const newChapterTitleField = 'input.e2e-test-new-chapter-title-field'; +const newChapterExplorationIdField = 'input.e2e-test-chapter-exploration-input'; +const newChapterPhotoBoxButton = + '.e2e-test-chapter-input-thumbnail .e2e-test-photo-button'; +const mobileChapterCollapsibleCard = '.e2e-test-mobile-add-chapter'; +const createChapterButton = 'button.e2e-test-confirm-chapter-creation-button'; +const publishStoryButton = 'button.e2e-test-publish-story-button'; +const unpublishStoryButton = 'button.e2e-test-unpublish-story-button'; +const skillDescriptionField = 'input.e2e-test-new-skill-description-field'; +const skillReviewMaterialHeader = 'div.e2e-test-open-concept-card'; +const addSkillButton = 'button.e2e-test-add-skill-button'; +const confirmSkillCreationButton = + 'button.e2e-test-confirm-skill-creation-button'; +const topicNameField = 'input.e2e-test-new-topic-name-field'; +const topicUrlFragmentField = + '.e2e-test-new-topic-url-fragment-field .e2e-test-url-fragment-field'; +const topicWebFragmentField = 'input.e2e-test-new-page-title-fragm-field'; +const topicDescriptionField = 'textarea.e2e-test-new-topic-description-field'; +const createTopicButton = 'button.e2e-test-confirm-topic-creation-button'; +const topicMetaTagInput = '.e2e-test-topic-meta-tag-content-field'; +const subtopicPhotoBoxButton = + '.e2e-test-subtopic-thumbnail .e2e-test-photo-button'; +const addSubtopicButton = 'button.e2e-test-add-subtopic-button'; +const subtopicTitleField = 'input.e2e-test-subtopic-title-field'; +const subtopicUrlFragmentField = + '.e2e-test-create-new-subtopic .e2e-test-url-fragment-field'; +const subtopicDescriptionEditorToggle = 'div.e2e-test-show-schema-editor'; +const createSubtopicButton = '.e2e-test-confirm-subtopic-creation-button'; +const photoBoxButton = 'div.e2e-test-photo-button'; +const createNewTopicButton = '.e2e-test-create-topic-button'; +const createNewTopicMobileButton = '.e2e-test-create-topic-mobile-button'; +const insertWorkedExampleButton = '.cke_button__oppiaworkedexample'; +const editWorkedExampleModalQuestionRte = + '.e2e-test-arg-editor-inner-0 .e2e-test-rte'; +const editWorkedExampleModalAnswerRte = + '.e2e-test-arg-editor-inner-1 .e2e-test-rte'; +const rteComponentSaveButton = '.e2e-test-close-rich-text-component-editor'; + +export class TopicManager extends BaseUser { + /** + * Create a basic algebra question in the skill editor page. + * @param {string} skillName The name of the skill to which the question will be added. + */ + async addBasicAlgebraQuestionToSkill(skillName: string): Promise { + await this.openSkillEditor(skillName); + await this.clickOnElementWithSelector(createQuestionButton); + await this.clickOnElementWithSelector(textStateEditSelector); + await this.expectElementToBeVisible(richTextAreaField); + await this.typeInInputField(richTextAreaField, 'Add 1+2'); + await this.expectElementToBeVisible(`${saveContentButton}:not([disabled])`); + await this.clickOnElementWithSelector(saveContentButton); + + await this.clickOnElementWithSelector(addInteractionButton); + await this.expectElementToBeVisible(interactionNumberInputButton); + + await this.clickOnElementWithSelectorAndText( + interactionNameDiv, + 'Number Input' + ); + + await this.clickOnElementWithSelector(saveInteractionButton); + await this.expectElementToBeVisible( + 'oppia-add-answer-group-modal-component' + ); + await this.clickOnElementWithSelector(responseRuleDropdown); + await this.clickOnElementWithText(equalsRuleButtonText); + await this.typeInInputField(floatTextField, '3'); + await this.clickOnElementWithSelector(answersInGroupAreCorrectToggle); + await this.clickOnElementWithSelector(openAnswerGroupFeedBackEditor); + await this.typeInInputField(richTextAreaField, 'Good job!'); + await this.clickOnElementWithSelector(saveResponseButton); + await this.expectElementToBeVisible(modalDiv, false); + + await this.clickOnElementWithSelector(defaultFeedbackTab); + await this.clickOnElementWithSelector(openOutcomeFeedBackEditor); + await this.clickOnElementWithSelector(richTextAreaField); + await this.typeInInputField(richTextAreaField, 'The answer is 3'); + await this.clickOnElementWithSelector(saveOutcomeFeedbackButton); + + await this.clickOnElementWithSelector(addHintButton); + await this.expectElementToBeVisible(modalDiv); + await this.typeInInputField(richTextAreaField, '3'); + await this.clickOnElementWithSelector(saveHintButton); + await this.expectElementToBeVisible(modalDiv, false); + + await this.clickOnElementWithSelector(addSolutionButton); + await this.expectElementToBeVisible(modalDiv); + await this.expectElementToBeVisible(answerTypeDropdown); + await this.select(answerTypeDropdown, 'The only'); + await this.expectElementToBeVisible(solutionFloatTextField); + await this.typeInInputField(solutionFloatTextField, '3'); + await this.expectElementToBeVisible( + `${submitAnswerButton}:not([disabled])` + ); + await this.clickOnElementWithSelector(submitAnswerButton); + await this.typeInInputField(richTextAreaField, '1+2 is 3'); + await this.expectElementToBeVisible( + `${submitSolutionButton}:not([disabled])` + ); + await this.clickOnElementWithSelector(submitSolutionButton); + await this.expectElementToBeVisible(modalDiv, false); + + await this.clickOnElementWithSelector(saveQuestionButton); + + await this.waitForNetworkIdle(); + await this.expectElementToBeVisible(modalDiv, false); + } + + /** + * Create a chapter for a certain story. + * @param {string} chapterName The name of the chapter to be created. + * @param {string} explorationId The ID of the exploration to be added to the chapter. + */ + async addChapter(chapterName: string, explorationId: string): Promise { + if (this.isViewportAtMobileWidth()) { + await this.waitForStaticAssetsToLoad(); + const addChapterButtonElement = await this.page.$(addChapterButton); + if (!addChapterButtonElement) { + await this.clickOnElementWithSelector(mobileChapterCollapsibleCard); + } + } + await this.expectElementToBeVisible(addChapterButton); + await this.clickOnElementWithSelector(addChapterButton); + await this.typeInInputField(newChapterTitleField, chapterName); + await this.typeInInputField(newChapterExplorationIdField, explorationId); + + await this.clickOnElementWithSelector(newChapterPhotoBoxButton); + await this.uploadFile(curriculumAdminThumbnailImage); + await this.expectElementToBeVisible(`${uploadPhotoButton}:not([disabled])`); + await this.clickOnElementWithSelector(uploadPhotoButton); + + await this.expectElementToBeVisible(photoUploadModal, false); + await this.clickOnElementWithSelector(createChapterButton); + await this.expectElementToBeVisible(modalDiv, false); + showMessage(`Chapter ${chapterName} is created.`); + } + + /** + * Adds a prerequisite topic to a topic in a classroom. + * @param {string} topicName The name of the topic. + * @param {string} prerequisiteTopicName The name of the prerequisite topic. + */ + async addPrerequisiteTopicForATopicInClassroom( + topicName: string, + prerequisiteTopicName: string + ): Promise { + const topicBox = await this.expectClassroomToContainTopic(topicName); + + const prerequisiteInputElement = await this.getElementInParent( + matFormFieldSelector, + topicBox + ); + if (!prerequisiteInputElement) { + throw new Error('Prerequisite input element not found'); + } + await this.clickOnElement(prerequisiteInputElement); + + await this.selectMatOption(prerequisiteTopicName); + await this.expectMatChipToBeVisible(prerequisiteTopicName); + } + + /** + * Add a skill for diagnostic test and then publish the topic. + * Adding a skill to diagnostic test is necessary for publishing the topic. + * @param {string} skillName The name of the skill to be added to the diagnostic test. + * @param {string} topicName The name of the topic to which the skill will be added. + */ + async addSkillToDiagnosticTest( + skillName: string, + topicName?: string + ): Promise { + if (topicName) { + await this.openTopicEditor(topicName); + } + await this.clickOnElementWithSelector(addDiagnosticTestSkillButton); + await this.expectElementToBeVisible(diagnosticTestSkillSelector); + await this.clickOnElementWithSelector(diagnosticTestSkillSelector); + + /** + * We select the skill in the dropdown with this method because the event doesn't propagate + * otherwise and no further changes are made to the DOM, even though the option is selected. + */ + await this.page.evaluate( + ({ + optionValue, + selectElemSelector, + }: { + optionValue: string; + selectElemSelector: string; + }) => { + const selectElem = document.querySelector( + selectElemSelector + ) as HTMLSelectElement | null; + if (!selectElem) { + console.error('Select element not found'); + return; + } + + const option = Array.from(selectElem.options).find( + opt => opt.textContent?.trim() === optionValue + ) as HTMLOptionElement | undefined; + if (!option) { + console.error('Option not found'); + return; + } + + option.selected = true; + const event = new Event('change', {bubbles: true}); + selectElem.dispatchEvent(event); + }, + {optionValue: skillName, selectElemSelector: diagnosticTestSkillSelector} + ); + if (!topicName) { + throw new Error('topicName is undefined'); + } + await this.saveTopicDraft(topicName); + } + + /** + * Creates a new story with the given title, URL fragment, and topic name. + * Note: This function only creates a story and does not add any chapters to it. + * @param {string} storyTitle - The title of the story. + * @param {string} storyUrlFragment - The URL fragment of the story. + * @param {string} topicName - The name of the topic. + * @param {string} metaTag - The meta tag of the story. + * @param {string} photoURL - The URL of the photo of the story. + */ + async addStoryToTopic( + storyTitle: string, + storyUrlFragment: string, + topicName: string, + metaTag: string = 'meta', + photoURL: string = curriculumAdminThumbnailImage + ): Promise { + await this.openTopicEditor(topicName); + if (this.isViewportAtMobileWidth()) { + await this.clickOnElementWithSelector(mobileStoryDropdown); + } + await this.clickOnElementWithSelector(addStoryButton); + await this.typeInInputField(storyTitleField, storyTitle); + await this.expectElementToBeVisible(storyUrlFragmentField); + await this.typeInInputField(storyUrlFragmentField, storyUrlFragment); + await this.typeInInputField( + storyDescriptionField, + `Story creation description for ${storyTitle}.` + ); + + await this.clickOnElementWithSelector(storyPhotoBoxButton); + await this.uploadFile(photoURL); + await this.expectElementToBeVisible(`${uploadPhotoButton}:not([disabled])`); + await this.clickOnElementWithSelector(uploadPhotoButton); + + await this.expectElementToBeVisible(photoUploadModal, false); + await this.clickAndWaitForNavigation(createStoryButton, true); + + await this.expectElementToBeVisible(storyMetaTagInput); + await this.page.focus(storyMetaTagInput); + await this.typeInInputField(storyMetaTagInput, metaTag); + await this.page.keyboard.press('Tab'); + await this.saveStoryDraft(); + + const url = new URL(this.page.url()); + const pathSegments = url.pathname.split('/'); + const storyId = pathSegments[pathSegments.length - 1]; + showMessage(`Story ${storyTitle} is created.`); + await this.waitForNetworkIdle(); + + return storyId; + } + + /** + * Function for adding a topic to a classroom. + * @param {string} classroomName - The name of the classroom. + * @param {string} topicName - The name of the topic. + * @param {string[]} prerequisiteTopics - The prerequisite topics of the topic. + */ + async addTopicToClassroom( + classroomName: string, + topicName: string, + prerequisiteTopics: string[] = [] + ): Promise { + await this.navigateToClassroomAdminPage(); + await this.editClassroom(classroomName); + + await this.clickOnElementWithSelector(openTopicDropdownButton); + await this.clickOnElementWithSelector(topicDropDownFormField); + await this.expectElementToBeVisible(addTopicFormFieldInput); + await this.typeInInputField(addTopicFormFieldInput, topicName); + + await this.expectElementToBeVisible(topicSelector); + await this.clickOnElementWithSelectorAndText(topicSelector, topicName); + + await this.expectElementToBeVisible(openTopicDropdownButton); + + await this.waitForNetworkIdle(); // Wait for the topic to appear in the classroom before adding prerequisites. + + // Increased timeout to 60s because addTopicId makes an async API call that can take time. + await this.page.waitForFunction( + ({ + topicBoxSelector, + topicNameSelector, + expectedTopicName, + }: { + topicBoxSelector: string; + topicNameSelector: string; + expectedTopicName: string; + }) => { + const topicBoxElements = document.querySelectorAll(topicBoxSelector); + for (const element of topicBoxElements) { + const topicNameElement = element.querySelector(topicNameSelector); + if (topicNameElement?.textContent?.trim() === expectedTopicName) { + return true; + } + } + return false; + }, + { + topicBoxSelector: classroomTopicBoxSelector, + topicNameSelector: classroomTopicNameSelector, + expectedTopicName: topicName, + }, + {timeout: 60000} + ); + + for (const prerequisiteTopic of prerequisiteTopics) { + await this.addPrerequisiteTopicForATopicInClassroom( + topicName, + prerequisiteTopic + ); + } + + await this.clickOnElementWithSelector(saveClassroomButton); + await this.expectElementToBeVisible(saveClassroomButton, false); + + showMessage(`Added ${topicName} topic to the ${classroomName} classroom.`); + } + + /** + * Assign a skill to a subtopic in the topic editor page. + * @param {string} skillName The name of the skill to be assigned. + * @param {string} subtopicName The name of the subtopic to which the skill will be assigned. + * @param {string} topicName The name of the topic containing the subtopic. + */ + async assignSkillToSubtopicInTopicEditor( + skillName: string, + subtopicName: string, + topicName: string + ): Promise { + await this.openTopicEditor(topicName); + if (this.isViewportAtMobileWidth()) { + await this.clickOnElementWithSelector(subtopicReassignHeader); + } + + await this.expectElementToBeVisible('div.e2e-test-skill-item'); + await this.page.evaluate( + ({ + skillName, + topicName, + editSkillItemSelector, + }: { + skillName: string; + topicName: string; + editSkillItemSelector: string; + }) => { + const skillItemDivs = Array.from( + document.querySelectorAll('div.e2e-test-skill-item') + ); + const element = skillItemDivs.find( + el => el.textContent?.trim() === skillName + ) as HTMLElement; + if (element) { + const assignSkillButton = element.querySelector( + editSkillItemSelector + ) as HTMLElement; + assignSkillButton.click(); + } else { + throw new Error( + `Cannot find skill called "${skillName}" in ${topicName}.` + ); + } + }, + {skillName, topicName, editSkillItemSelector} + ); + + await this.expectElementToBeVisible(assignSubtopicButton); + await this.clickOnElementWithText('Assign to Subtopic'); + + await this.clickOnElementWithSelectorAndText( + subtopicNameSelector, + subtopicName + ); + + await this.expectElementToBeVisible( + `${confirmSkillAssignationButton}:not([disabled])` + ); + await this.clickOnElementWithSelector(confirmSkillAssignationButton); + await this.expectElementToBeVisible(changeSubtopicAssignmentModal, false); + await this.saveTopicDraft(topicName); + } + + /** + * Create a story, execute chapter creation for + * the story, and then publish the story. + * @param {string} storyTitle - The title of the story. + * @param {string} storyUrlFragment - The URL fragment for the story. + * @param {string} chapterTitle - The title of the chapter to be added to the story. + * @param {string} explorationId - The ID of the exploration to be added to the chapter. + * @param {string} topicName - The name of the topic to which the story will be added (optional). + */ + async createAndPublishStoryWithChapter( + storyTitle: string, + storyUrlFragment: string, + chapterTitle: string, + explorationId: string, + topicName?: string + ): Promise { + if (topicName) { + await this.openTopicEditor(topicName); + } + if (this.isViewportAtMobileWidth()) { + await this.clickOnElementWithSelector(mobileStoryDropdown); + } + await this.clickOnElementWithSelector(addStoryButton); + await this.typeInInputField(storyTitleField, storyTitle); + await this.expectElementToBeVisible(storyUrlFragmentField); + await this.typeInInputField(storyUrlFragmentField, storyUrlFragment); + await this.typeInInputField( + storyDescriptionField, + `Story creation description for ${storyTitle}.` + ); + + await this.clickOnElementWithSelector(storyPhotoBoxButton); + await this.uploadFile(curriculumAdminThumbnailImage); + await this.expectElementToBeVisible(`${uploadPhotoButton}:not([disabled])`); + await this.clickOnElementWithSelector(uploadPhotoButton); + + await this.expectElementToBeVisible(photoUploadModal, false); + await this.clickAndWaitForNavigation(createStoryButton, true); + + await this.expectElementToBeVisible(storyMetaTagInput); + await this.page.focus(storyMetaTagInput); + await this.typeInInputField(storyMetaTagInput, 'meta'); + await this.page.keyboard.press('Tab'); + + await this.addChapter(chapterTitle, explorationId); + + await this.saveStoryDraft(); + if (this.isViewportAtMobileWidth()) { + await this.clickOnElementWithSelector(mobileSaveStoryChangesDropdown); + await this.expectElementToBeVisible(mobilePublishStoryButton); + await this.clickOnElementWithSelector(mobilePublishStoryButton); + } else { + await this.expectElementToBeVisible( + `${publishStoryButton}:not([disabled])` + ); + await this.clickOnElementWithSelector(publishStoryButton); + await this.expectElementToBeVisible(unpublishStoryButton); + } + } + + /** + * Creates and publishes a topic with a subtopic and skill. + * @param {string} topicName - The name of the topic. + * @param {string} subtopicName - The name of the subtopic. + * @param {string} skillName - The name of the skill. + */ + async createAndPublishTopic( + topicName: string, + subtopicName: string, + skillName: string + ): Promise { + await this.createTopic( + topicName, + topicName.toLowerCase().replace(/ /g, '-') + ); + await this.createSubtopicForTopic( + subtopicName, + subtopicName.toLowerCase().replace(/ /g, '-'), + topicName + ); + + await this.createSkillForTopic(skillName, topicName, false); + await this.createQuestionsForSkill(skillName, 3); + await this.assignSkillToSubtopicInTopicEditor( + skillName, + subtopicName, + topicName + ); + await this.addSkillToDiagnosticTest(skillName, topicName); + + await this.publishDraftTopic(topicName); + } + + /** + * Add any number of questions to a particular skill. + * @param {string} skillName The name of the skill to which the questions will be added. + * @param {number} questionCount The number of questions to be added. + */ + async createQuestionsForSkill( + skillName: string, + questionCount: number + ): Promise { + for (let i = 0; i < questionCount; i++) { + await this.addBasicAlgebraQuestionToSkill(skillName); + } + } + + /** + * Create a skill for a particular topic. + * @param {string} description - The description of the skill to be created. + * @param {string} topicName - The name of the topic for which the skill is + * to be created. + * @param {boolean} addWorkedExample - True if the skill should have a + * WorkedExample, false otherwise. + */ + async createSkillForTopic( + description: string, + topicName: string, + addWorkedExample: boolean = false + ): Promise { + await this.openTopicEditor(topicName); + if (this.isViewportAtMobileWidth()) { + await this.clickOnElementWithSelector(subtopicReassignHeader); + } + await this.expectElementToBeVisible(addSkillButton); + await this.clickOnElementWithSelector(addSkillButton); + await this.fillSkillInfoAndSubmit( + description, + `Review material text content for ${description}.`, + addWorkedExample + ); + } + + /** + * Create a subtopic as a curriculum admin. + * @param {string} title - The title of the Subtopic. + * @param {string} urlFragment - The url fragment of the Subtopic. + * @param {string} topicName - The name of the Topic which storing the new Subtopic. + */ + async createSubtopicForTopic( + title: string, + urlFragment: string, + topicName: string + ): Promise { + await this.openTopicEditor(topicName); + if (this.isViewportAtMobileWidth()) { + await this.clickOnElementWithSelector(subtopicReassignHeader); + } + await this.clickOnElementWithSelector(addSubtopicButton); + await this.typeInInputField(subtopicTitleField, title); + await this.expectElementToBeVisible(subtopicUrlFragmentField); + await this.typeInInputField(subtopicUrlFragmentField, urlFragment); + + await this.clickOnElementWithSelector(subtopicDescriptionEditorToggle); + await this.expectElementToBeVisible(richTextAreaField); + await this.typeInInputField( + richTextAreaField, + `Subtopic creation description text for ${title}` + ); + + await this.clickOnElementWithSelector(subtopicPhotoBoxButton); + await this.expectElementToBeVisible(photoUploadModal); + await this.uploadFile(curriculumAdminThumbnailImage); + await this.expectElementToBeVisible(`${uploadPhotoButton}:not([disabled])`); + await this.clickOnElementWithSelector(uploadPhotoButton); + + await this.expectElementToBeVisible(photoUploadModal, false); + await this.clickOnElementWithSelector(createSubtopicButton); + await this.saveTopicDraft(topicName); + showMessage(`Subtopic ${title} is created.`); + } + + /** + * Create a topic in the topics-and-skills dashboard. + * @param {string} name - The name of the topic. + * @param {string} urlFragment - The URL fragment for the topic. + * @returns {Promise} - A promise that resolves to the ID of the created topic. + */ + async createTopic(name: string, urlFragment: string): Promise { + await this.navigateToTopicsAndSkillsDashboardPageAsTopicManager(); + let TopicSelectorElement = null; + try { + TopicSelectorElement = await this.expectElementToBeAttachedInDOM( + desktopTopicSelector, + this.page, + 10000 + ); + } catch { + // Element didn't appear in 10 seconds — treat as not present. + } + + if (!TopicSelectorElement || !this.isViewportAtMobileWidth()) { + await this.clickOnElementWithSelector(createNewTopicButton); + } else { + await this.clickOnElementWithSelector(createNewTopicMobileButton); + } + + await this.typeInInputField(topicNameField, name); + await this.expectElementToBeVisible(topicUrlFragmentField); + await this.typeInInputField(topicUrlFragmentField, urlFragment); + await this.typeInInputField(topicWebFragmentField, name); + await this.typeInInputField( + topicDescriptionField, + `Topic creation description test for ${name}.` + ); + + await this.clickOnElementWithSelector(photoBoxButton); + await this.expectElementToBeVisible(photoUploadModal); + await this.uploadFile(curriculumAdminThumbnailImage); + await this.expectElementToBeVisible(`${uploadPhotoButton}:not([disabled])`); + await this.clickOnElementWithSelector(uploadPhotoButton); + await this.expectElementToBeVisible(photoUploadModal, false); + await this.clickOnElementWithSelector(createTopicButton); + + await this.expectElementToBeAttachedInDOM('.e2e-test-topics-table'); + await this.openTopicEditor(name); + await this.expectElementToBeVisible(topicMetaTagInput); + await this.page.focus(topicMetaTagInput); + await this.typeInInputField(topicMetaTagInput, 'meta'); + await this.page.keyboard.press('Tab'); + await this.saveTopicDraft(name); + const topicUrl = this.page.url(); + let topicId = topicUrl + .replace(/^.*\/topic_editor\//, '') + .replace(/#\/.*/, ''); + + return topicId; + } + + /** + * Function for opening the classroom tile in edit mode. + * @param {string} classroomName - The name of the classroom to be edited. + */ + async editClassroom(classroomName: string): Promise { + await this.navigateToClassroomAdminPage(); + await this.expectElementToBeVisible(classroomTileSelector); + const classroomTiles = await this.page.$$(classroomTileSelector); + + if (classroomTiles.length === 0) { + throw new Error('No classrooms are present.'); + } + + let foundClassroom = false; + + for (let i = 0; i < classroomTiles.length; i++) { + const currentClassroomName = await classroomTiles[i].$eval( + classroomTileNameSpan, + element => (element as HTMLSpanElement).innerText.trim() + ); + + if (currentClassroomName === classroomName) { + await this.clickOnElement(classroomTiles[i]); + await this.expectElementToBeVisible(editClassroomConfigButton); + await this.clickOnElementWithSelector(editClassroomConfigButton); + await this.expectElementToBeVisible(closeClassroomConfigButton); + + foundClassroom = true; + break; + } + } + + if (!foundClassroom) { + throw new Error(`${classroomName} classroom does not exist.`); + } + } + + /** + * Checks if the classroom contains a topic with the given name. + * @param {string} topicName The name of the topic to check for. + * @returns {Promise>} A promise that resolves to the ElementHandle + * of the topic box if found, or throws an error if not found. + */ + async expectClassroomToContainTopic( + topicName: string + ): Promise> { + await this.expectElementToBeVisible(classroomTopicBoxSelector); + + const topicBoxElements = await this.page.$$(classroomTopicBoxSelector); + let topicBoxElement: ElementHandle | null = null; + + for (const element of topicBoxElements) { + const topicBoxElementText = await element.$eval( + classroomTopicNameSelector, + element => element.textContent?.trim() + ); + if (topicBoxElementText === topicName) { + topicBoxElement = element; + break; + } + } + + if (!topicBoxElement) { + throw new Error(`Topic ${topicName} not found in classroom.`); + } + + return topicBoxElement; + } + + /** + * Fills the skill info and submits the form. + * @param {string} skillName The name of the skill. + * @param {string} reviewMaterial The review material text content. + * @param {boolean} addWorkedExample Whether to add a worked example. + */ + async fillSkillInfoAndSubmit( + skillName: string, + reviewMaterial: string, + addWorkedExample: boolean = false + ): Promise { + await this.typeInInputField(skillDescriptionField, skillName); + await this.expectElementToBeVisible(skillReviewMaterialHeader); + await this.clickOnElementWithSelector(skillReviewMaterialHeader); + await this.clickOnElementWithSelector(richTextAreaField); + await this.typeInInputField(richTextAreaField, reviewMaterial); + if (addWorkedExample) { + await this.clickOnElementWithSelector(insertWorkedExampleButton); + await this.expectElementToBeVisible(editWorkedExampleModalQuestionRte); + await this.clearAllTextFrom(editWorkedExampleModalQuestionRte); + await this.typeInInputField( + editWorkedExampleModalQuestionRte, + 'Type the number one' + ); + await this.expectElementToBeVisible(editWorkedExampleModalAnswerRte); + await this.clearAllTextFrom(editWorkedExampleModalAnswerRte); + await this.waitForElementToStabilize(editWorkedExampleModalAnswerRte); + await this.typeInInputField(editWorkedExampleModalAnswerRte, '1'); + await this.clickOnElementWithSelector(rteComponentSaveButton); + } + await this.expectElementToBeVisible( + `${confirmSkillCreationButton}:not([disabled])` + ); + await this.clickOnElementWithSelector(confirmSkillCreationButton); + await this.waitForNetworkIdle(); + await this.expectElementToBeVisible(confirmSkillCreationButton, false); + await this.page.bringToFront(); + } + + /** + * Function for navigating to the classroom admin page. + */ + async navigateToClassroomAdminPage(): Promise { + await this.page.bringToFront(); + await this.waitForNetworkIdle(); + await this.goto(classroomAdminUrl); + } + + /** + * Navigate to the question editor tab present in the skills tab. + */ + async navigateToSkillQuestionEditorTab(): Promise { + const isMobileWidth = this.isViewportAtMobileWidth(); + const skillQuestionTab = isMobileWidth + ? mobileSkillQuestionTab + : desktopSkillQuestionTab; + + if (isMobileWidth) { + await this.page.waitForFunction(() => + window.location.href.includes('skill_editor') + ); + const currentUrl = new URL(this.page.url()); + const hashParts = currentUrl.hash.split('/'); + + if (hashParts.length > 1) { + hashParts[1] = 'questions'; + } else { + hashParts.push('questions'); + } + currentUrl.hash = hashParts.join('/'); + await this.goto(currentUrl.toString()); + // Changing only the URL hash triggers a same-document navigation in + // the browser (no reload, no re-run of the app's bootstrap code), + // so the app never re-evaluates the hash to switch tabs. A full + // reload is required to force the app to re-initialize and pick up + // the 'questions' tab from the updated hash. + await this.reloadPage(); + } else { + await this.expectElementToBeVisible(skillQuestionTab); + await this.clickAndWaitForNavigation(skillQuestionTab, true); + } + await this.expectElementToBeVisible(addQuestionButton); + } + + /** + * Navigate to the topic and skills dashboard page. + */ + async navigateToTopicsAndSkillsDashboardPageAsTopicManager(): Promise { + const navigationUtils = new NavigationUtils(this); + await navigationUtils.navigateToTopicsAndSkillsDashboardPage(); + } + + /** + * Open the skill editor page for a skill. + * @param {string} skillName - The name of the skill to be opened in the editor. + */ + async openSkillEditor(skillName: string): Promise { + const skillSelector = this.isViewportAtMobileWidth() + ? mobileSkillSelector + : desktopSkillSelector; + await this.page.bringToFront(); + await this.navigateToTopicsAndSkillsDashboardPageAsTopicManager(); + await this.clickOnElementWithSelector(skillsTab); + await this.expectElementToBeVisible(skillSelector); + await this.clickOnElementWithSelectorAndText(skillSelector, skillName); + await this.expectElementToBeVisible(skillEditorCollapsibleCard); + + expect(this.page.url()).toContain('/skill_editor/'); + } + + /** + * Open the topic editor page for a topic. + * @param {string} topicName - The name of the topic to be opened in the editor. + */ + async openTopicEditor(topicName: string): Promise { + const topicNameSelector = this.isViewportAtMobileWidth() + ? mobileTopicSelector + : desktopTopicSelector; + await this.navigateToTopicsAndSkillsDashboardPageAsTopicManager(); + await this.clickOnElementWithSelector(topicsTab); + await this.expectElementToBeVisible(topicNameSelector); + + await Promise.all([ + this.clickOnElementWithSelectorAndText(topicNameSelector, topicName), + this.page.waitForNavigation(), + ]); + + expect(this.page.url()).toContain('/topic_editor/'); + } + + /** + * Publishes a topic draft. + * @param {string} topicName - Optional. If not provided, the topic editor will be opened. + */ + async publishDraftTopic(topicName: string): Promise { + await this.openTopicEditor(topicName); + if (this.isViewportAtMobileWidth()) { + await this.clickOnElementWithSelector(mobileOptionsSelector); + await this.clickOnElementWithSelector(mobileSaveTopicDropdown); + await this.expectElementToBeVisible(mobilePublishTopicButton); + await this.clickOnElementWithSelector(mobilePublishTopicButton); + await this.expectElementToBeVisible(mobilePublishTopicButton, false); + } else { + await this.clickOnElementWithSelector(publishTopicButton); + + await this.expectElementToBeVisible(publishTopicButton, false); + } + } + + /** + * Publish a story. + */ + async publishStoryDraft(): Promise { + if (this.isViewportAtMobileWidth()) { + await this.expectElementToBeVisible(mobileSaveStoryChangesDropdown); + await this.clickOnElementWithSelector(mobileSaveStoryChangesDropdown); + await this.expectElementToBeVisible(mobilePublishStoryButton); + await this.clickOnElementWithSelector(mobilePublishStoryButton); + + await this.page.waitForFunction((selector: string) => { + const element = document.querySelector(selector); + return element?.textContent?.trim() === 'Unpublish Story'; + }, mobilePublishStoryButton); + } else { + await this.expectElementToBeVisible( + `${publishStoryButton}:not([disabled])` + ); + await this.clickOnElementWithSelector(publishStoryButton); + await this.expectElementToBeVisible(unpublishStoryButton); + } + } + + /** + * Save a story. + */ + async saveStoryDraft(): Promise { + if (this.isViewportAtMobileWidth()) { + const isMobileSaveButtonVisible = await this.isElementVisible( + mobileSaveStoryChangesButton + ); + if (!isMobileSaveButtonVisible) { + await this.clickOnElementWithSelector(mobileOptionsSelector); + } + await this.expectElementToBeVisible(mobileSaveStoryChangesButton); + await this.clickOnElementWithSelector(mobileSaveStoryChangesButton); + } else { + await this.expectElementToBeVisible(saveStoryButton); + await this.clickOnElementWithSelector(saveStoryButton); + } + await this.typeInInputField( + saveChangesMessageInput, + 'Test saving story as curriculum admin.' + ); + await this.expectElementToBeVisible( + `${closeSaveModalButton}:not([disabled])` + ); + await this.clickOnElementWithSelector(closeSaveModalButton); + await this.expectElementToBeVisible(modalDiv, false); + } + + /** + * Save a topic as a curriculum admin. + * @param {string} topicName - The name of the Topic whose draft is to be saved. + */ + async saveTopicDraft(topicName?: string): Promise { + await this.expectElementToBeVisible(modalDiv, false); + if (this.isViewportAtMobileWidth()) { + await this.clickOnElementWithSelector(mobileOptionsSelector); + await this.clickOnElementWithSelector(mobileSaveTopicButton); + await this.expectElementToBeVisible('oppia-topic-editor-save-modal'); + await this.typeInInputField( + saveChangesMessageInput, + 'Test saving topic as curriculum admin.' + ); + await this.expectElementToBeVisible( + `${closeSaveModalButton}:not([disabled])` + ); + await this.clickOnElementWithSelector(closeSaveModalButton); + await this.expectElementToBeVisible( + 'oppia-topic-editor-save-modal', + false + ); + if (topicName) { + await this.openTopicEditor(topicName); + } + } else { + await this.clickOnElementWithSelector(saveTopicButton); + + await this.expectElementToBeVisible(modalDiv); + await this.typeInInputField( + saveChangesMessageInput, + 'Test saving topic as curriculum admin.' + ); + await this.expectElementToBeVisible( + `${closeSaveModalButton}:not([disabled])` + ); + await this.clickOnElementWithSelector(closeSaveModalButton); + await this.expectElementToBeVisible(modalDiv, false); + } + } + + /** + * Toggles the "Show practice tab to learners" in Topic Editor. + */ + async togglePracticeTabCheckbox(): Promise { + if (this.isViewportAtMobileWidth()) { + await this.expectElementToBeVisible(subtopicExpandHeaderSelector); + await this.clickOnElementWithSelector(subtopicExpandHeaderSelector); + } + try { + await this.clickOnElementWithSelector(practiceTabToggle); + + await this.page.waitForFunction( + (selector: string) => { + const element = document.querySelector(selector); + return (element as HTMLInputElement).checked === true; + }, + practiceTabToggle, + {timeout: 60000} + ); + } catch (error) { + console.error(error instanceof Error ? error.stack : error); + throw error; + } + } +} export let TopicManagerFactory = (page: Page): TopicManager => { return new TopicManager(page); }; diff --git a/core/tests/puppeteer-acceptance-tests/specs/blog-editor/dev-desktop-screenshots/blogEditorPageWithErrorMessageForDuplicateBlogPostTitle-snap.png b/core/tests/puppeteer-acceptance-tests/specs/blog-editor/dev-desktop-screenshots/blogEditorPageWithErrorMessageForDuplicateBlogPostTitle-snap.png index 96e2f3cbcf791..c3f8aafb91768 100644 Binary files a/core/tests/puppeteer-acceptance-tests/specs/blog-editor/dev-desktop-screenshots/blogEditorPageWithErrorMessageForDuplicateBlogPostTitle-snap.png and b/core/tests/puppeteer-acceptance-tests/specs/blog-editor/dev-desktop-screenshots/blogEditorPageWithErrorMessageForDuplicateBlogPostTitle-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/blog-editor/prod-desktop-screenshots/blogEditorPageWithErrorMessageForDuplicateBlogPostTitle-snap.png b/core/tests/puppeteer-acceptance-tests/specs/blog-editor/prod-desktop-screenshots/blogEditorPageWithErrorMessageForDuplicateBlogPostTitle-snap.png index ca658925ab021..0cd7a5ab463f6 100644 Binary files a/core/tests/puppeteer-acceptance-tests/specs/blog-editor/prod-desktop-screenshots/blogEditorPageWithErrorMessageForDuplicateBlogPostTitle-snap.png and b/core/tests/puppeteer-acceptance-tests/specs/blog-editor/prod-desktop-screenshots/blogEditorPageWithErrorMessageForDuplicateBlogPostTitle-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/apply-to-become-a-volunteer.spec.ts b/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/apply-to-become-a-volunteer.spec.ts index db3e1ee94d54f..6d77fee594bf8 100644 --- a/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/apply-to-become-a-volunteer.spec.ts +++ b/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/apply-to-become-a-volunteer.spec.ts @@ -16,7 +16,7 @@ * @fileoverview Acceptance test from CUJv3 Doc * https://docs.google.com/document/d/1D7kkFTzg3rxUe3QJ_iPlnxUzBFNElmRkmAWss00nFno/ * - * IV.VP. Volunteer applies to become a volunteer + * IV.VP. Volunteer explores volunteer openings on the Oppia Idealist page */ import {UserFactory} from '../../utilities/common/user-factory'; @@ -29,7 +29,7 @@ describe('Interested Volunteer', function () { interestedVolunteer = await UserFactory.createLoggedOutUser(); }); - it('should be able to apply to become a volunteer', async function () { + it('should be able to explore volunteer openings on the Oppia Idealist page', async function () { await interestedVolunteer.navigateToSplashPage(); // Navigate to Volunteer Page. @@ -44,8 +44,8 @@ describe('Interested Volunteer', function () { 'Volunteer to make a difference' ); - // Apply to become a volunteer at top of the page. - await interestedVolunteer.clickApplyToVolunteerAtTheTopOfVolunteerPage(); + // Explore volunteer openings at the top of the page. + await interestedVolunteer.clickExploreVolunteerOpeningsButtonAtTheTopOfVolunteerPage(); // "Why Volunteer with Us?" heading. await interestedVolunteer.navigateToVolunteerPage(); @@ -59,8 +59,8 @@ describe('Interested Volunteer', function () { ); await interestedVolunteer.expectVolunteerExpectationsTabsToBeFunctionalInVolunteerPage(); - // Open Volunteer Form. - await interestedVolunteer.clickApplyToVolunteerAtTheBottomOfVolunteerPage(); + // Open Oppia Idealist page. + await interestedVolunteer.clickExploreVolunteerOpeningsButtonAtTheBottomOfVolunteerPage(); }); afterAll(async function () { diff --git a/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/dev-desktop-screenshots/volunteerPage-snap.png b/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/dev-desktop-screenshots/volunteerPage-snap.png index a4c5c305deb55..c079640565001 100644 Binary files a/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/dev-desktop-screenshots/volunteerPage-snap.png and b/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/dev-desktop-screenshots/volunteerPage-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/dev-mobile-screenshots/volunteerPage-snap.png b/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/dev-mobile-screenshots/volunteerPage-snap.png index 1996582e4179e..61ec4df7dc0c3 100644 Binary files a/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/dev-mobile-screenshots/volunteerPage-snap.png and b/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/dev-mobile-screenshots/volunteerPage-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/prod-desktop-screenshots/volunteerPage-snap.png b/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/prod-desktop-screenshots/volunteerPage-snap.png index e7c96937bec52..45f63a88f9b7b 100644 Binary files a/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/prod-desktop-screenshots/volunteerPage-snap.png and b/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/prod-desktop-screenshots/volunteerPage-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/prod-mobile-screenshots/volunteerPage-snap.png b/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/prod-mobile-screenshots/volunteerPage-snap.png index 9e7c844a886f6..352017e6268a2 100644 Binary files a/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/prod-mobile-screenshots/volunteerPage-snap.png and b/core/tests/puppeteer-acceptance-tests/specs/interested-volunteer/prod-mobile-screenshots/volunteerPage-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorAfterEditingAdventureMetadata-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorAfterEditingAdventureMetadata-snap.png new file mode 100644 index 0000000000000..a096689b2f90c Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorAfterEditingAdventureMetadata-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorAfterReloadPersistedGroupings-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorAfterReloadPersistedGroupings-snap.png new file mode 100644 index 0000000000000..1ba0b57121b5d Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorAfterReloadPersistedGroupings-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorAfterRemovingAdventureBoundary-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorAfterRemovingAdventureBoundary-snap.png new file mode 100644 index 0000000000000..6ac59832640ee Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorAfterRemovingAdventureBoundary-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorAfterSplitAtChapter3-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorAfterSplitAtChapter3-snap.png new file mode 100644 index 0000000000000..5da97ab21f7a3 Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorAfterSplitAtChapter3-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorAllChaptersInSingleAdventure-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorAllChaptersInSingleAdventure-snap.png new file mode 100644 index 0000000000000..9af7d7cc9bfc9 Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorAllChaptersInSingleAdventure-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorEditAdventureModal-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorEditAdventureModal-snap.png new file mode 100644 index 0000000000000..b052d219a806b Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-desktop-screenshots/storyEditorEditAdventureModal-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorAfterEditingAdventureMetadata-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorAfterEditingAdventureMetadata-snap.png new file mode 100644 index 0000000000000..cbd80e24557ad Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorAfterEditingAdventureMetadata-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorAfterReloadPersistedGroupings-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorAfterReloadPersistedGroupings-snap.png new file mode 100644 index 0000000000000..9a55c379ae828 Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorAfterReloadPersistedGroupings-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorAfterRemovingAdventureBoundary-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorAfterRemovingAdventureBoundary-snap.png new file mode 100644 index 0000000000000..cbd80e24557ad Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorAfterRemovingAdventureBoundary-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorAfterSplitAtChapter3-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorAfterSplitAtChapter3-snap.png new file mode 100644 index 0000000000000..cbd80e24557ad Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorAfterSplitAtChapter3-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorAllChaptersInSingleAdventure-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorAllChaptersInSingleAdventure-snap.png new file mode 100644 index 0000000000000..cbd80e24557ad Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorAllChaptersInSingleAdventure-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorEditAdventureModal-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorEditAdventureModal-snap.png new file mode 100644 index 0000000000000..4acf106845b69 Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/dev-mobile-screenshots/storyEditorEditAdventureModal-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/manage-story-adventures.spec.ts b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/manage-story-adventures.spec.ts new file mode 100644 index 0000000000000..46ecdec17d8aa --- /dev/null +++ b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/manage-story-adventures.spec.ts @@ -0,0 +1,223 @@ +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Acceptance test from CUJv3 Doc + * https://docs.google.com/document/d/1mDDP9joYRWjYExWghmPcqV4RTut6BOMqqUHOkxL_npI/edit?tab=t.4t336fwwj9ly + * + * TM.SA Manage story adventures in the story editor. + */ + +import testConstants from '../../utilities/common/test-constants'; +import {UserFactory} from '../../utilities/common/user-factory'; +import {CurriculumAdmin} from '../../utilities/user/curriculum-admin'; +import {ExplorationEditor} from '../../utilities/user/exploration-editor'; +import {ReleaseCoordinator} from '../../utilities/user/release-coordinator'; +import {TopicManager} from '../../utilities/user/topic-manager'; + +const ROLES = testConstants.Roles; +const DEFAULT_SPEC_TIMEOUT_MSECS = testConstants.DEFAULT_SPEC_TIMEOUT_MSECS; +const CHAPTER_TITLES = Array.from({length: 11}, (_, i) => `Chapter ${i + 1}`); + +describe('Topic Manager', function () { + let curriculumAdmin: CurriculumAdmin & ExplorationEditor; + let topicManager: TopicManager & CurriculumAdmin & ExplorationEditor; + let releaseCoordinator: ReleaseCoordinator; + const explorationIds: string[] = []; + + beforeAll(async function () { + curriculumAdmin = await UserFactory.createNewUser( + 'curriculumAdm', + 'curriculum_adm@example.com', + [ROLES.CURRICULUM_ADMIN] + ); + + releaseCoordinator = await UserFactory.createNewUser( + 'releaseAdm', + 'release_adm@example.com', + [ROLES.RELEASE_COORDINATOR] + ); + + await releaseCoordinator.enableFeatureFlag('story_editor_arcs'); + await UserFactory.closeBrowserForUser(releaseCoordinator); + + for (let i = 0; i < 11; i++) { + const id = await curriculumAdmin.createAndPublishExplorationWithCards( + `Exploration ${i + 1}`, + 'Mathematics' + ); + explorationIds.push(id); + } + + await curriculumAdmin.createAndPublishTopic( + 'Adventure Topic', + 'adventure-topic', + 'Adventure Topic' + ); + await curriculumAdmin.createAndPublishClassroom( + 'Maths', + 'maths', + 'Adventure Topic' + ); + + topicManager = await UserFactory.createNewUser( + 'topicManager', + 'topic_manager@example.com', + [ROLES.TOPIC_MANAGER], + 'Adventure Topic' + ); + + await curriculumAdmin.addStoryToTopic( + 'The Adventure Story', + 'the-adventure-story', + 'Adventure Topic' + ); + + for (let i = 0; i < CHAPTER_TITLES.length; i++) { + await curriculumAdmin.addChapter(`Chapter ${i + 1}`, explorationIds[i]); + } + + await curriculumAdmin.saveStoryDraft(); + // TODO(#27082): Reduce the setup time for this spec while migrating to + // Playwright. The 45-minute timeout is needed because the beforeAll hook + // creates 11 published explorations, a topic, a classroom, and an + // 11-chapter story, which can take 30+ minutes on slow CI runners. + }, 2700000); + + it( + 'should create a new adventure from existing chapters', + async function () { + await topicManager.openStoryEditor( + 'The Adventure Story', + 'Adventure Topic' + ); + + await topicManager.expectAllChaptersInSingleAdventure(CHAPTER_TITLES); + + await topicManager.scrollToTopOfPage(); + + await topicManager.expectScreenshotToMatch( + 'storyEditorAllChaptersInSingleAdventure', + __dirname + ); + + await topicManager.splitIntoAdventure('Chapter 3'); + await topicManager.expectAdventureCount(2); + await topicManager.expectAdventureHeaderToBeVisible('Adventure 2'); + + await topicManager.scrollToTopOfPage(); + + await topicManager.expectScreenshotToMatch( + 'storyEditorAfterSplitAtChapter3', + __dirname + ); + + await topicManager.expectChaptersOrderToBe(CHAPTER_TITLES); + }, + DEFAULT_SPEC_TIMEOUT_MSECS + ); + + it( + 'should edit an adventure metadata', + async function () { + await topicManager.fillEditAdventureModal( + 'Part Two', + 'The second part of the story' + ); + + await topicManager.expectScreenshotToMatch( + 'storyEditorEditAdventureModal', + __dirname + ); + + await topicManager.saveEditAdventureModal(); + + await topicManager.expectAdventureToHave( + 'Part Two', + 'The second part of the story' + ); + + await topicManager.scrollToTopOfPage(); + + await topicManager.expectScreenshotToMatch( + 'storyEditorAfterEditingAdventureMetadata', + __dirname + ); + + await topicManager.saveStoryDraft(); + + await topicManager.openStoryEditor( + 'The Adventure Story', + 'Adventure Topic' + ); + + await topicManager.expectAdventureToHave( + 'Part Two', + 'The second part of the story' + ); + }, + DEFAULT_SPEC_TIMEOUT_MSECS + ); + + it( + 'should remove an adventure boundary', + async function () { + await topicManager.removeAdventureBoundary(); + await topicManager.expectAdventureCount(1); + + await topicManager.splitIntoAdventure('Chapter 7'); + await topicManager.expectAdventureCount(2); + + await topicManager.removeAdventureBoundary(); + await topicManager.expectAdventureCount(1); + + await topicManager.closeStoryEditorMobileNavbarOptions(); + + await topicManager.scrollToTopOfPage(); + + await topicManager.expectScreenshotToMatch( + 'storyEditorAfterRemovingAdventureBoundary', + __dirname + ); + }, + DEFAULT_SPEC_TIMEOUT_MSECS + ); + + it( + 'should save changes in the story with adventure groupings', + async function () { + await topicManager.splitIntoAdventure('Chapter 7'); + await topicManager.expectAdventureCount(2); + + await topicManager.saveStoryDraft(); + + await topicManager.openStoryEditor( + 'The Adventure Story', + 'Adventure Topic' + ); + + await topicManager.expectAdventureCount(2); + + await topicManager.expectScreenshotToMatch( + 'storyEditorAfterReloadPersistedGroupings', + __dirname + ); + }, + DEFAULT_SPEC_TIMEOUT_MSECS + ); + + afterAll(async function () { + await UserFactory.closeAllBrowsers(); + }); +}); diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorAfterEditingAdventureMetadata-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorAfterEditingAdventureMetadata-snap.png new file mode 100644 index 0000000000000..162e50a229333 Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorAfterEditingAdventureMetadata-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorAfterReloadPersistedGroupings-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorAfterReloadPersistedGroupings-snap.png new file mode 100644 index 0000000000000..3601384a81b40 Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorAfterReloadPersistedGroupings-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorAfterRemovingAdventureBoundary-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorAfterRemovingAdventureBoundary-snap.png new file mode 100644 index 0000000000000..f01f61733557c Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorAfterRemovingAdventureBoundary-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorAfterSplitAtChapter3-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorAfterSplitAtChapter3-snap.png new file mode 100644 index 0000000000000..557c8825852e0 Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorAfterSplitAtChapter3-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorAllChaptersInSingleAdventure-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorAllChaptersInSingleAdventure-snap.png new file mode 100644 index 0000000000000..584faa61f7e8d Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorAllChaptersInSingleAdventure-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorEditAdventureModal-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorEditAdventureModal-snap.png new file mode 100644 index 0000000000000..71c97c464b03a Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-desktop-screenshots/storyEditorEditAdventureModal-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorAfterEditingAdventureMetadata-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorAfterEditingAdventureMetadata-snap.png new file mode 100644 index 0000000000000..f0b42e92f23bf Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorAfterEditingAdventureMetadata-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorAfterReloadPersistedGroupings-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorAfterReloadPersistedGroupings-snap.png new file mode 100644 index 0000000000000..fb2d52e27ffc8 Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorAfterReloadPersistedGroupings-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorAfterRemovingAdventureBoundary-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorAfterRemovingAdventureBoundary-snap.png new file mode 100644 index 0000000000000..f0b42e92f23bf Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorAfterRemovingAdventureBoundary-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorAfterSplitAtChapter3-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorAfterSplitAtChapter3-snap.png new file mode 100644 index 0000000000000..f0b42e92f23bf Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorAfterSplitAtChapter3-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorAllChaptersInSingleAdventure-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorAllChaptersInSingleAdventure-snap.png new file mode 100644 index 0000000000000..f0b42e92f23bf Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorAllChaptersInSingleAdventure-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorEditAdventureModal-snap.png b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorEditAdventureModal-snap.png new file mode 100644 index 0000000000000..1cb0cb085ef36 Binary files /dev/null and b/core/tests/puppeteer-acceptance-tests/specs/topic-manager/prod-mobile-screenshots/storyEditorEditAdventureModal-snap.png differ diff --git a/core/tests/puppeteer-acceptance-tests/utilities/common/puppeteer-utils.ts b/core/tests/puppeteer-acceptance-tests/utilities/common/puppeteer-utils.ts index 39de5cc8762f8..07db28ffdcaa5 100644 --- a/core/tests/puppeteer-acceptance-tests/utilities/common/puppeteer-utils.ts +++ b/core/tests/puppeteer-acceptance-tests/utilities/common/puppeteer-utils.ts @@ -809,14 +809,44 @@ export class BaseUser { * The function selects all text content and delete it. */ async clearAllTextFrom(selector: string): Promise { - // Clicking three times on a line of text selects all the text. const element = await this.getElementInParent(selector); await this.waitForElementToBeClickable(element); - await element.click(); - await this.page.keyboard.down('Control'); - await this.page.keyboard.press('A'); - await this.page.keyboard.up('Control'); - await this.page.keyboard.press('Backspace'); + + const isTextInput = await element.evaluate( + el => el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement + ); + + if (isTextInput) { + // Click the field to move the pointer (so hover-paused toasts dismiss) + // and to focus it before clearing, matching the keyboard-only behavior. + await element.click(); + + // Clear via the native value setter and an input event to update ngModel + // deterministically without depending on focus/selection timing. Do not + // dispatch 'change' here: change-bound editors (e.g. the URL fragment + // editor) would commit an empty value to their model before the user + // types; the native 'change' fires on the next blur with the full value. + await element.evaluate(el => { + const valueSetter = Object.getOwnPropertyDescriptor( + el instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype, + 'value' + )?.set; + + valueSetter?.call(el, ''); + el.dispatchEvent(new Event('input', {bubbles: true})); + }); + } else { + // Rich-text editors (e.g. CKEditor) expose contenteditable divs, which + // cannot be cleared by setting their text directly without desyncing the + // editor's internal model. Clear them with real keyboard events instead. + await element.click(); + await this.page.keyboard.down('Control'); + await this.page.keyboard.press('A'); + await this.page.keyboard.up('Control'); + await this.page.keyboard.press('Backspace'); + } } /** @@ -1354,15 +1384,17 @@ export class BaseUser { * * If the network does not become idle within the specified timeout, this function will log a message and continue. This is * because the main objective of the test is to interact with the page, not specifically to ensure that the network becomes - * idle within a certain timeframe. However, a timeout of 30 seconds should be sufficient for the network to become idle in - * almost all cases and for the page to fully load. + * idle within a certain timeframe. + * + * The default timeout is intentionally short because this helper is best-effort; on busy CI runners, a long default timeout + * can significantly inflate total setup time across many calls. * - * @param {Object} options The options to pass to page.waitForNetworkIdle. Defaults to {timeout: 30000, idleTime: 500}. + * @param {Object} options The options to pass to page.waitForNetworkIdle. Defaults to {timeout: 5000, idleTime: 500}. * @param {Page} page The page to wait for network idle. Defaults to the current page. */ async waitForNetworkIdle( options: {timeout?: number; idleTime?: number} = { - timeout: 30000, + timeout: 5000, idleTime: 500, }, page: Page = this.page diff --git a/core/tests/puppeteer-acceptance-tests/utilities/common/test-constants.ts b/core/tests/puppeteer-acceptance-tests/utilities/common/test-constants.ts index a3a9e6acec67d..6e5fc7e5a31c7 100644 --- a/core/tests/puppeteer-acceptance-tests/utilities/common/test-constants.ts +++ b/core/tests/puppeteer-acceptance-tests/utilities/common/test-constants.ts @@ -102,9 +102,8 @@ export default { 'http://localhost:8181/topics-and-skills-dashboard', ProgrammingWithCarla: 'https://www.oppia.org/collection/inDXV0w8-p1C', Volunteer: 'http://localhost:8181/volunteer', - VolunteerForm: - 'https://docs.google.com/forms/d/e/1FAIpQLSc5_rwUjugT_Jt_EB49_zAKWVY68I3fTXF5w9b5faIk7rL6yg/viewform', - VolunteerFormShortUrl: 'https://forms.gle/rhFYoLLSFr3JEZHy8', + VolunteerIdealistPage: + 'https://www.idealist.org/en/nonprofit/e436a3f9282f42439350aa6f0c335072-oppia-foundation-inc-sacramento', WelcomeToOppia: 'https://www.oppia.org/explore/0', WikiPrivilegesToFirebaseAccount: 'https://github.com/oppia/oppia/wiki/#2-add-custom-claims-to-a-firebase-account', diff --git a/core/tests/puppeteer-acceptance-tests/utilities/user/exploration-editor.ts b/core/tests/puppeteer-acceptance-tests/utilities/user/exploration-editor.ts index ff871590a5a9c..5ee291f870c16 100644 --- a/core/tests/puppeteer-acceptance-tests/utilities/user/exploration-editor.ts +++ b/core/tests/puppeteer-acceptance-tests/utilities/user/exploration-editor.ts @@ -781,6 +781,14 @@ export class ExplorationEditor extends BaseUser { await this.typeInInputField(historyUserFilterSelector, username); await this.page.keyboard.press('Enter'); + + // ClearAllTextFrom clears the field programmatically, so the input is not + // marked as user-edited and Enter alone does not fire a change event. Since + // the filter only re-applies on change, dispatch it explicitly so that the + // filter reflects the current input value. + await this.page.$eval(historyUserFilterSelector, el => { + el.dispatchEvent(new Event('change', {bubbles: true})); + }); } /** diff --git a/core/tests/puppeteer-acceptance-tests/utilities/user/logged-out-user.ts b/core/tests/puppeteer-acceptance-tests/utilities/user/logged-out-user.ts index 3e2f09f124c00..d38c2c195d6f4 100644 --- a/core/tests/puppeteer-acceptance-tests/utilities/user/logged-out-user.ts +++ b/core/tests/puppeteer-acceptance-tests/utilities/user/logged-out-user.ts @@ -67,7 +67,7 @@ const teachUrl = testConstants.URLs.Teach; const termsUrl = testConstants.URLs.Terms; const donatePageThanksModalURL = testConstants.URLs.DonatePageThanksModalURL; const aboutPageThanksModalURL = testConstants.URLs.AboutPageThanksModalURL; -const volunteerFormUrl = testConstants.URLs.VolunteerForm; +const volunteerIdealistPageUrl = testConstants.URLs.VolunteerIdealistPage; const volunteerUrl = testConstants.URLs.Volunteer; const robotsTxtUrl = testConstants.URLs.RobotsTxt; const sitemapXmlUrl = testConstants.URLs.SitemapXml; @@ -217,10 +217,10 @@ const readBlogPostDesktopButtonInPartnershipsPage = '.e2e-test-partnerships-page-blog-post-desktop-button'; const readBlogPostMobileButtonInPartnershipsPage = '.e2e-test-partnerships-page-blog-post-mobile-button'; -const applyToVolunteerButtonAtTheTopOfVolunteerPage = - '.e2e-test-volunteer-page-apply-to-volunteer-button-at-the-top'; -const applyToVolunteerButtonAtTheBottomOfVolunteerPage = - '.e2e-test-volunteer-page-apply-to-volunteer-button-at-the-bottom'; +const exploreVolunteerOpeningsButtonAtTheTopOfVolunteerPage = + '.e2e-test-volunteer-page-explore-volunteer-openings-button-at-the-top'; +const exploreVolunteerOpeningsButtonAtTheBottomOfVolunteerPage = + '.e2e-test-volunteer-page-explore-volunteer-openings-button-at-the-bottom'; const tabsSectionInVolunteerPage = '.e2e-test-volunteer-page-tabs-section'; const tabsPreviousButtonInVolunteerPage = '.e2e-test-volunteer-page-tabs-prev-btn'; @@ -2747,28 +2747,28 @@ export class LoggedOutUser extends BaseUser { } /** - * Function to click the Apply To Volunteer at the top of the Volunteer page - * and check if it opens the Volunteer form. + * Function to click the Explore Volunteer Openings button at the top of the + * Volunteer page and check if it opens the Oppia Idealist page. */ - async clickApplyToVolunteerAtTheTopOfVolunteerPage(): Promise { + async clickExploreVolunteerOpeningsButtonAtTheTopOfVolunteerPage(): Promise { await this.clickLinkButtonToNewTab( - applyToVolunteerButtonAtTheTopOfVolunteerPage, - 'Apply To Volunteer at the top of the Volunteer page', - volunteerFormUrl, - 'Volunteer Form' + exploreVolunteerOpeningsButtonAtTheTopOfVolunteerPage, + 'Explore Volunteer Openings at the top of the Volunteer page', + volunteerIdealistPageUrl, + 'Oppia Idealist page' ); } /** - * Function to click the Apply To Volunteer at the bottom of the Volunteer page - * and check if it opens the Volunteer form. + * Function to click the Explore Volunteer Openings button at the bottom of the + * Volunteer page and check if it opens the Oppia Idealist page. */ - async clickApplyToVolunteerAtTheBottomOfVolunteerPage(): Promise { + async clickExploreVolunteerOpeningsButtonAtTheBottomOfVolunteerPage(): Promise { await this.clickLinkButtonToNewTab( - applyToVolunteerButtonAtTheBottomOfVolunteerPage, - 'Apply To Volunteer at the bottom of the Volunteer page', - volunteerFormUrl, - 'Volunteer Form' + exploreVolunteerOpeningsButtonAtTheBottomOfVolunteerPage, + 'Explore Volunteer Openings at the bottom of the Volunteer page', + volunteerIdealistPageUrl, + 'Oppia Idealist page' ); } @@ -3505,7 +3505,7 @@ export class LoggedOutUser extends BaseUser { /** * Function to click the Volunteer with Oppia on the about page - * and check if it opens the Volunteer form. + * and check if it opens the Oppia Idealist page. */ async clickVolunteerWithOppiaButtonInAboutPage(): Promise { const volunteerWithOppiaButtonInAboutPage = this.isViewportAtMobileWidth() @@ -3513,9 +3513,9 @@ export class LoggedOutUser extends BaseUser { : volunteerWithOppiaDesktopButtonInAboutPage; await this.clickLinkButtonToNewTab( volunteerWithOppiaButtonInAboutPage, - 'Apply To Volunteer at the top of the Volunteer page', - volunteerFormUrl, - 'Volunteer Form' + 'Volunteer with Oppia at the bottom of the About page', + volunteerIdealistPageUrl, + 'Oppia Idealist page' ); } diff --git a/core/tests/puppeteer-acceptance-tests/utilities/user/topic-manager.ts b/core/tests/puppeteer-acceptance-tests/utilities/user/topic-manager.ts index d27e93c510001..dac89ef5ceee8 100644 --- a/core/tests/puppeteer-acceptance-tests/utilities/user/topic-manager.ts +++ b/core/tests/puppeteer-acceptance-tests/utilities/user/topic-manager.ts @@ -403,6 +403,14 @@ const mobileAcquiredSkillsSectionBodySelector = '.e2e-test-section-body-acquired-skills'; const warningIndicatorSelector = '.e2e-test-warning-indicator'; const warningTextSelector = '.e2e-test-warnings-text'; + +// Adventure (Arc) selectors. +const arcEditButtonSelector = '.arc-edit-button'; +const arcRemoveButtonSelector = '.arc-remove-button'; +const editArcTitleFieldSelector = '.e2e-test-edit-arc-title-field'; +const editArcDescriptionFieldSelector = '.e2e-test-edit-arc-description-field'; +const saveEditArcButtonSelector = '.e2e-test-save-edit-arc-button'; + export class TopicManager extends BaseUser { /** * Closes navigation in mobile view. @@ -5724,5 +5732,250 @@ export class TopicManager extends BaseUser { await this.publishStoryDraftChapterUpto(dropdownValue); await this.publishStoryDraftSerialChapter(); } + + /** + * Splits into a new adventure (arc) after the specified chapter. + * Finds the split button that appears between the target chapter and the + * next chapter, and clicks it to create a new adventure boundary. + * @param {string} afterChapterName - The name of the chapter after which + * to split. + */ + async splitIntoAdventure(afterChapterName: string): Promise { + await this.expectChapterListIsVisible(); + const chapterTitleElements = await this.page.$$(chapterTitleSelector); + + let foundTarget = false; + for (const titleElement of chapterTitleElements) { + const title = await this.page.evaluate( + el => el.textContent?.trim() ?? '', + titleElement + ); + + if (foundTarget) { + const splitButtonHandle = await titleElement.evaluateHandle(el => { + const parent = + el.closest('[cdkDrag]') || + el.closest('.story-editor-node')?.parentElement; + if (!parent) { + return null; + } + return parent.querySelector('.split-into-arc-button'); + }); + + const splitButtonElement = splitButtonHandle.asElement(); + if (splitButtonElement) { + await this.clickOnElement(splitButtonElement); + await this.waitForPageToFullyLoad(); + showMessage( + `Split adventure created after chapter "${afterChapterName}".` + ); + return; + } + + throw new Error( + `Split button not found between "${afterChapterName}" and "${title}". ` + + 'They may already be in different adventures.' + ); + } + + if (title === afterChapterName) { + foundTarget = true; + } + } + + if (!foundTarget) { + throw new Error(`Chapter "${afterChapterName}" not found.`); + } + throw new Error(`No chapter found after "${afterChapterName}" to split.`); + } + + /** + * Opens the edit modal for the first non-default adventure and fills in its + * title and description without saving. + * @param {string} title - The new title for the adventure. + * @param {string} description - The new description for the adventure. + */ + async fillEditAdventureModal( + title: string, + description?: string + ): Promise { + const editButtons = await this.page.$$(arcEditButtonSelector); + if (editButtons.length < 2) { + throw new Error( + 'No non-default adventure found to edit. ' + + `Only ${editButtons.length} adventure(s) present.` + ); + } + await this.clickOnElement(editButtons[1]); + await this.expectElementToBeVisible(editArcTitleFieldSelector); + + // Wait until the modal form is fully initialized (title populated) before + // clearing, otherwise the clear may run before ngModel sets the value. + await this.page.waitForFunction( + (modalSelector: string) => { + const el = document.querySelector( + modalSelector + ) as HTMLInputElement | null; + return el !== null && el.value.length > 0; + }, + {timeout: 15000}, + editArcTitleFieldSelector + ); + + await this.clearAllTextFrom(editArcTitleFieldSelector); + await this.typeInInputField(editArcTitleFieldSelector, title); + await this.expectElementValueToBe(editArcTitleFieldSelector, title); + + if (description !== undefined) { + await this.clearAllTextFrom(editArcDescriptionFieldSelector); + await this.typeInInputField(editArcDescriptionFieldSelector, description); + await this.expectElementValueToBe( + editArcDescriptionFieldSelector, + description + ); + } + } + + /** Saves the adventure metadata and closes the edit modal. */ + async saveEditAdventureModal(): Promise { + await this.clickOnElementWithSelector(saveEditArcButtonSelector); + await this.expectElementToBeVisible(editArcTitleFieldSelector, false); + } + + /** + * Closes the mobile navbar options panel if it is open. This is needed + * before story editor screenshots so that the capture is deterministic even + * when a previous test in the same suite failed before re-navigating to the + * story editor (which is what would normally have collapsed the panel). + */ + async closeStoryEditorMobileNavbarOptions(): Promise { + if (!this.isViewportAtMobileWidth()) { + return; + } + if (await this.isElementVisible(navigationContainerSelector, true, 1000)) { + await this.clickOnElementWithSelector(mobileOptionsSelector); + await this.expectElementToBeVisible(navigationContainerSelector, false); + } + } + + /** + * Removes the last adventure boundary by clicking the Remove Arc Boundary + * button on the last non-default adventure header. The chapters from the + * removed adventure are merged into the previous adventure. + */ + async removeAdventureBoundary(): Promise { + const removeButtons = await this.page.$$(arcRemoveButtonSelector); + if (removeButtons.length < 1) { + throw new Error('No removable adventure boundary found.'); + } + await this.clickOnElement(removeButtons[removeButtons.length - 1]); + await this.waitForPageToFullyLoad(); + showMessage('Adventure boundary removed.'); + } + + /** + * Expects an adventure header with the given title to be visible or not. + * @param {string} title - The expected adventure title. + * @param {boolean} visible - Whether the adventure header should be visible. + */ + async expectAdventureHeaderToBeVisible( + title: string, + visible: boolean = true + ): Promise { + await this.expectChapterListIsVisible(); + await this.page.waitForFunction( + (titleText: string, shouldBeVisible: boolean) => { + const headers = document.querySelectorAll('.arc-boundary-title'); + const found = Array.from(headers).some( + el => el.textContent?.trim() === titleText + ); + return found === shouldBeVisible; + }, + {timeout: 10000}, + title, + visible + ); + showMessage( + `Adventure header "${title}" is ${visible ? 'visible' : 'not visible'} ` + + 'as expected.' + ); + } + + /** + * Expects the number of adventure (arc) boundaries to match the given count. + * @param {number} count - The expected number of adventures. + */ + async expectAdventureCount(count: number): Promise { + await this.expectChapterListIsVisible(); + await this.page.waitForFunction( + (expectedCount: number) => { + const headers = document.querySelectorAll('.arc-boundary-header'); + return headers.length === expectedCount; + }, + {timeout: 10000}, + count + ); + showMessage(`Expected ${count} adventures found.`); + } + + /** + * Expects an adventure header to have the given title and description. + * @param {string} title - The expected adventure title. + * @param {string} description - The expected adventure description. + */ + async expectAdventureToHave( + title: string, + description?: string + ): Promise { + await this.expectChapterListIsVisible(); + await this.page.waitForFunction( + (titleText: string) => { + const headers = document.querySelectorAll('.arc-boundary-header'); + return Array.from(headers).some(header => { + const titleEl = header.querySelector('.arc-boundary-title'); + return titleEl?.textContent?.trim() === titleText; + }); + }, + {timeout: 10000}, + title + ); + + if (description !== undefined) { + await this.page.waitForFunction( + (titleText: string, descText: string) => { + const headers = document.querySelectorAll('.arc-boundary-header'); + return Array.from(headers).some(header => { + const titleEl = header.querySelector('.arc-boundary-title'); + const descEl = header.querySelector('.arc-boundary-description'); + return ( + titleEl?.textContent?.trim() === titleText && + descEl?.textContent?.trim() === descText + ); + }); + }, + {timeout: 10000}, + title, + description + ); + } + + showMessage(`Adventure "${title}" has the expected metadata.`); + } + + /** + * Expects the given chapters to be in the default "All Chapters" adventure. + * This is verified by checking that no other adventure boundaries exist. + * @param {string[]} chapterNames - The chapter names expected in the story. + */ + async expectAllChaptersInSingleAdventure( + chapterNames: string[] + ): Promise { + await this.expectAdventureCount(1); + await this.expectAdventureHeaderToBeVisible('All Chapters'); + await this.expectChaptersOrderToBe(chapterNames); + showMessage( + `All chapters [${chapterNames.join(', ')}] are in a single adventure.` + ); + } } export let TopicManagerFactory = (): TopicManager => new TopicManager(); diff --git a/extensions/interactions/NumberWithUnits/directives/oppia-help-modal-number-with-units.component.ts b/extensions/interactions/NumberWithUnits/directives/oppia-help-modal-number-with-units.component.ts index df97506196847..b965293e2a300 100644 --- a/extensions/interactions/NumberWithUnits/directives/oppia-help-modal-number-with-units.component.ts +++ b/extensions/interactions/NumberWithUnits/directives/oppia-help-modal-number-with-units.component.ts @@ -16,8 +16,9 @@ * @fileoverview Component for Number With Units Help Modal. */ -import {Component} from '@angular/core'; +import {Component, Optional} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {MatBottomSheetRef} from '@angular/material/bottom-sheet'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; @Component({ @@ -25,7 +26,11 @@ import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-e templateUrl: './number-with-units-help-modal.component.html', }) export class HelpModalNumberWithUnitsComponent extends ConfirmOrCancelModal { - constructor(ngbActiveModal: NgbActiveModal) { - super(ngbActiveModal); + constructor( + @Optional() ngbActiveModal: NgbActiveModal, + @Optional() + numberWithUnitsHelpBottomSheetRef?: MatBottomSheetRef + ) { + super(ngbActiveModal, numberWithUnitsHelpBottomSheetRef); } } diff --git a/extensions/interactions/PencilCodeEditor/directives/pencil-code-reset-confirmation.component.spec.ts b/extensions/interactions/PencilCodeEditor/directives/pencil-code-reset-confirmation.component.spec.ts index 020d5fc87295a..875d2c167127e 100644 --- a/extensions/interactions/PencilCodeEditor/directives/pencil-code-reset-confirmation.component.spec.ts +++ b/extensions/interactions/PencilCodeEditor/directives/pencil-code-reset-confirmation.component.spec.ts @@ -21,6 +21,8 @@ import {MockTranslatePipe} from 'tests/unit-test-utils'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {NO_ERRORS_SCHEMA} from '@angular/core'; import {PencilCodeResetConfirmation} from './pencil-code-reset-confirmation.component'; +import {MatBottomSheetRef} from '@angular/material/bottom-sheet'; +import {Subject} from 'rxjs'; class MockActiveModal { dismiss(): void { @@ -69,3 +71,59 @@ describe('Pencil Code Reset Confirmation Modal', () => { expect(dismissSpy).toHaveBeenCalled(); }); }); + +describe('Pencil Code Reset Confirmation Modal in bottom sheet mode', () => { + let component: PencilCodeResetConfirmation; + let fixture: ComponentFixture; + let bottomSheetRef: jasmine.SpyObj; + let keydownSubject: Subject; + + beforeEach(() => { + keydownSubject = new Subject(); + bottomSheetRef = jasmine.createSpyObj('MatBottomSheetRef', [ + 'dismiss', + 'keydownEvents', + ]); + bottomSheetRef.keydownEvents.and.returnValue(keydownSubject.asObservable()); + + TestBed.configureTestingModule({ + declarations: [PencilCodeResetConfirmation, MockTranslatePipe], + providers: [ + { + provide: NgbActiveModal, + useClass: MockActiveModal, + }, + { + provide: MatBottomSheetRef, + useValue: bottomSheetRef, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PencilCodeResetConfirmation); + component = fixture.componentInstance; + }); + + it('should dismiss the bottom sheet when confirmed', () => { + component.confirm(); + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith(true); + }); + + it('should dismiss the bottom sheet when cancelled', () => { + component.cancel(); + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith(false); + }); + + it('should dismiss the bottom sheet when Escape key is pressed', () => { + keydownSubject.next(new KeyboardEvent('keydown', {key: 'Escape'})); + expect(bottomSheetRef.dismiss).toHaveBeenCalled(); + }); + + it('should not dismiss the bottom sheet when a non-Escape key is pressed', () => { + keydownSubject.next(new KeyboardEvent('keydown', {key: 'Enter'})); + expect(bottomSheetRef.dismiss).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/interactions/PencilCodeEditor/directives/pencil-code-reset-confirmation.component.ts b/extensions/interactions/PencilCodeEditor/directives/pencil-code-reset-confirmation.component.ts index c39718dffe196..99d649020a88d 100644 --- a/extensions/interactions/PencilCodeEditor/directives/pencil-code-reset-confirmation.component.ts +++ b/extensions/interactions/PencilCodeEditor/directives/pencil-code-reset-confirmation.component.ts @@ -16,21 +16,42 @@ * @fileoverview Component for the Pencil code reset confirmation modal. */ -import {Component} from '@angular/core'; +import {Component, Optional} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {MatBottomSheetRef} from '@angular/material/bottom-sheet'; @Component({ selector: 'oppia-pencil-code-reset-confirmation', templateUrl: './pencil-code-reset-confirmation.component.html', }) export class PencilCodeResetConfirmation { - constructor(private ngbActiveModal: NgbActiveModal) {} + constructor( + @Optional() private ngbActiveModal: NgbActiveModal, + @Optional() + private pencilCodeResetBottomSheetRef?: MatBottomSheetRef + ) { + if (this.pencilCodeResetBottomSheetRef) { + this.pencilCodeResetBottomSheetRef.keydownEvents().subscribe(event => { + if (event.key === 'Escape') { + this.pencilCodeResetBottomSheetRef?.dismiss(); + } + }); + } + } confirm(): void { - this.ngbActiveModal.close(); + if (this.pencilCodeResetBottomSheetRef) { + this.pencilCodeResetBottomSheetRef.dismiss(true); + } else { + this.ngbActiveModal.close(); + } } cancel(): void { - this.ngbActiveModal.dismiss(); + if (this.pencilCodeResetBottomSheetRef) { + this.pencilCodeResetBottomSheetRef.dismiss(false); + } else { + this.ngbActiveModal.dismiss(); + } } } diff --git a/extensions/objects/templates/image-with-regions-reset-confirmation.component.ts b/extensions/objects/templates/image-with-regions-reset-confirmation.component.ts index ca182589a1097..72c1de301e95e 100644 --- a/extensions/objects/templates/image-with-regions-reset-confirmation.component.ts +++ b/extensions/objects/templates/image-with-regions-reset-confirmation.component.ts @@ -16,8 +16,9 @@ * @fileoverview Component for resetting image regions editor. */ -import {Component} from '@angular/core'; +import {Component, Optional} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {MatBottomSheetRef} from '@angular/material/bottom-sheet'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; @Component({ @@ -26,7 +27,11 @@ import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-e styleUrls: [], }) export class ImageWithRegionsResetConfirmationModalComponent extends ConfirmOrCancelModal { - constructor(ngbActiveModal: NgbActiveModal) { - super(ngbActiveModal); + constructor( + @Optional() ngbActiveModal: NgbActiveModal, + @Optional() + imageWithRegionsResetBottomSheetRef?: MatBottomSheetRef + ) { + super(ngbActiveModal, imageWithRegionsResetBottomSheetRef); } } diff --git a/index.yaml b/index.yaml index c000f9f48b3d6..b8e2a59325d20 100644 --- a/index.yaml +++ b/index.yaml @@ -699,4 +699,20 @@ indexes: - name: certificate_id - name: is_submitted - name: attempt_index + +- kind: LessonFeedbackModel + properties: + - name: deleted + - name: exploration_id + - name: status + - name: created_on + direction: desc + +- kind: PlatformFeedbackModel + properties: + - name: deleted + - name: destination_dashboard + - name: exploration_id + - name: status + - name: created_on direction: desc diff --git a/scripts/linters/html_style_tag_allowlist.txt b/scripts/linters/html_style_tag_allowlist.txt index 749ef91b4d69b..2303c66b85d18 100644 --- a/scripts/linters/html_style_tag_allowlist.txt +++ b/scripts/linters/html_style_tag_allowlist.txt @@ -18,26 +18,6 @@ core/templates/pages/exploration-editor-page/editor-tab/graph-directives/state-g core/templates/pages/exploration-editor-page/editor-tab/state-name-editor/state-name-editor.component.html core/templates/pages/exploration-editor-page/editor-tab/state-param-changes-editor/state-param-changes-editor.component.html core/templates/pages/exploration-editor-page/editor-tab/state-version-history/state-version-history.component.html -core/templates/pages/exploration-editor-page/improvements-tab/improvements-tab.component.html -core/templates/pages/exploration-editor-page/modal-templates/editor-reloading-modal.component.html -core/templates/pages/exploration-editor-page/modal-templates/exploration-metadata-diff-modal.component.html -core/templates/pages/exploration-editor-page/modal-templates/exploration-metadata-modal.component.html -core/templates/pages/exploration-editor-page/modal-templates/exploration-modify-translations-modal.component.html -core/templates/pages/exploration-editor-page/modal-templates/exploration-save-modal.component.html -core/templates/pages/exploration-editor-page/modal-templates/help-modal.component.html -core/templates/pages/exploration-editor-page/modal-templates/metadata-version-history-modal.component.html -core/templates/pages/exploration-editor-page/modal-templates/post-publish-modal.component.html -core/templates/pages/exploration-editor-page/modal-templates/state-diff-modal.component.html -core/templates/pages/exploration-editor-page/modal-templates/state-version-history-modal.component.html -core/templates/pages/exploration-editor-page/param-changes-editor/param-changes-editor.component.html -core/templates/pages/exploration-editor-page/preview-tab/preview-tab.component.html -core/templates/pages/exploration-editor-page/preview-tab/templates/preview-set-parameters-modal.component.html -core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.html -core/templates/pages/exploration-editor-page/settings-tab/templates/reassign-role-confirmation-modal.component.html -core/templates/pages/exploration-editor-page/settings-tab/templates/remove-role-confirmation-modal.component.html -core/templates/pages/exploration-editor-page/statistics-tab/templates/state-stats-modal.component.html -core/templates/pages/exploration-editor-page/translation-tab/modal-templates/add-audio-translation-modal.component.html -core/templates/pages/exploration-editor-page/translation-tab/modal-templates/welcome-translation-modal.component.html core/templates/pages/exploration-editor-page/translation-tab/state-translation-editor/state-translation-editor.component.html core/templates/pages/exploration-editor-page/translation-tab/state-translation-status-graph/state-translation-status-graph.component.html core/templates/pages/exploration-editor-page/translation-tab/state-translation/state-translation.component.html diff --git a/scripts/linters/modal_allowlist.json b/scripts/linters/modal_allowlist.json index a62144d56ae60..c635f1b9b5867 100644 --- a/scripts/linters/modal_allowlist.json +++ b/scripts/linters/modal_allowlist.json @@ -1,5 +1,4 @@ [ - "core/templates/base-components/feedback-modal.component.ts", "core/templates/base-components/oppia-footer.component.ts", "core/templates/base-components/thanks-for-subscribing-modal.component.ts", "core/templates/components/button-directives/exploration-embed-button-modal.component.ts", @@ -7,7 +6,6 @@ "core/templates/components/certificate-assessment-offering-helper/delete-certificate-offering-modal.component.ts", "core/templates/components/certificate-assessment-offering-helper/post-certificate-offering-result-modal.component.ts", "core/templates/components/common-layout-directives/common-elements/answer-content-modal.component.ts", - "core/templates/components/common-layout-directives/common-elements/confirm-or-cancel-modal.component.ts", "core/templates/components/common-layout-directives/navigation-bars/top-navigation-bar.component.ts", "core/templates/components/entity-creation-services/story-creation-backend-api.service.ts", "core/templates/components/entity-creation-services/topic-creation.service.ts", @@ -18,7 +16,6 @@ "core/templates/components/forms/forms-templates/mark-audio-as-needing-update-modal.component.ts", "core/templates/components/forms/forms-templates/mark-translations-as-needing-update-modal.component.ts", "core/templates/components/keyboard-shortcut-help/keyboard-shortcut-help-modal.component.ts", - "core/templates/components/question-directives/modal-templates/confirm-question-exit-modal.component.ts", "core/templates/components/question-directives/modal-templates/question-editor-save-modal.component.ts", "core/templates/components/question-directives/modal-templates/remove-question-skill-link-modal.component.ts", "core/templates/components/question-directives/question-misconception-editor/question-misconception-editor.component.ts", @@ -27,9 +24,7 @@ "core/templates/components/question-directives/question-player/question-player.component.ts", "core/templates/components/question-directives/question-player/skill-mastery-modal.component.ts", "core/templates/components/question-directives/questions-list/questions-list.component.ts", - "core/templates/components/save-pending-changes/save-pending-changes-modal.component.ts", "core/templates/components/skill-selector/merge-skill-modal.component.ts", - "core/templates/components/skill-selector/select-skill-modal.component.ts", "core/templates/components/stale-tab-info/stale-tab-info-modal.component.ts", "core/templates/components/state-directives/outcome-editor/outcome-editor.component.ts", "core/templates/components/state-editor/state-interaction-editor/state-interaction-editor.component.ts", @@ -224,7 +219,6 @@ "core/templates/pages/topic-editor-page/subtopic-editor/delete-study-guide-section-modal.component.ts", "core/templates/pages/topic-editor-page/topic-editor-page.component.ts", "core/templates/pages/topic-editor-page/topic-editor-page.module.ts", - "core/templates/pages/topic-viewer-page/deprecations/modals/practice-session-confirmation-modal.component.ts", "core/templates/pages/topics-and-skills-dashboard-page/modals/assign-skill-to-topic-modal.component.ts", "core/templates/pages/topics-and-skills-dashboard-page/modals/create-new-skill-modal.component.ts", "core/templates/pages/topics-and-skills-dashboard-page/modals/create-new-topic-modal.component.ts", @@ -238,11 +232,7 @@ "core/templates/pages/voiceover-admin-page/modals/language-accent-removal-confirm-modal.component.ts", "core/templates/pages/voiceover-admin-page/voiceover-admin-page.component.ts", "core/templates/services/keyboard-shortcut.service.ts", - "core/templates/services/rte-helper-modal.component.ts", "core/templates/services/suggestion-modal.service.ts", - "extensions/interactions/NumberWithUnits/directives/oppia-help-modal-number-with-units.component.ts", - "extensions/interactions/PencilCodeEditor/directives/pencil-code-reset-confirmation.component.ts", - "extensions/objects/templates/image-with-regions-reset-confirmation.component.ts", "extensions/rich_text_components/Skillreview/directives/oppia-noninteractive-skillreview-concept-card-modal.component.ts", "extensions/rich_text_components/Skillreview/directives/oppia-noninteractive-skillreview.component.ts", "extensions/visualizations/oppia-visualization-sorted-tiles.component.ts" diff --git a/scripts/linters/other_files_linter.py b/scripts/linters/other_files_linter.py index f002b1073ae5c..3053952294f46 100644 --- a/scripts/linters/other_files_linter.py +++ b/scripts/linters/other_files_linter.py @@ -47,6 +47,16 @@ class ThirdPartyLibDict(TypedDict): os.getcwd(), STRICT_TS_CONFIG_FILE_NAME ) +PLAYWRIGHT_USER_UTILITIES_DIR: Final = os.path.join( + os.getcwd(), + 'core', + 'tests', + 'playwright-acceptance-tests', + 'utilities', + 'user', +) +METHOD_NAME_REGEX: Final = r'async\s+(\w+)\s*\(' + APP_YAML_FILEPATH: Final = os.path.join(os.getcwd(), 'app_dev.yaml') PACKAGE_JSON_FILE_PATH: Final = os.path.join(os.getcwd(), 'package.json') @@ -97,6 +107,53 @@ def __init__(self, file_cache: run_lint_checks.FileCache) -> None: """ self.file_cache = file_cache + def check_duplicate_method_names_in_user_utilities( + self, + ) -> concurrent_task_utils.TaskResult: + """Checks that no method name is defined in more than one file + under the Playwright user utilities directory. + + Since UserFactory composes multiple role classes onto a single + user object, two files defining a method with the same name can + silently overwrite one another at runtime. This check ensures + every method name is unique across all user utility files. + + Returns: + TaskResult. A TaskResult object representing the result of the + lint check. + """ + name = 'Duplicate method names in user utilities' + + utility_filenames = { + filename + for filename in os.listdir(PLAYWRIGHT_USER_UTILITIES_DIR) + if filename.endswith('.ts') + } + + method_name_to_filenames: Dict[str, List[str]] = {} + for filename in utility_filenames: + filepath = os.path.join(PLAYWRIGHT_USER_UTILITIES_DIR, filename) + file_content = self.file_cache.read(filepath) + method_names = re.findall(METHOD_NAME_REGEX, file_content) + for method_name in method_names: + method_name_to_filenames.setdefault(method_name, []).append( + filename + ) + + error_messages = [] + for method_name, filenames in sorted(method_name_to_filenames.items()): + if len(filenames) > 1: + error_messages.append( + 'Method "%s" is defined in multiple user utility ' + 'files: %s. Rename to disambiguate, following the ' + 'convention {action}In{PageContext}Page.' + % (method_name, ', '.join(sorted(filenames))) + ) + + return concurrent_task_utils.TaskResult( + name, bool(error_messages), error_messages, error_messages + ) + def check_skip_files_in_app_dev_yaml( self, ) -> concurrent_task_utils.TaskResult: @@ -310,6 +367,9 @@ def perform_all_lint_checks(self) -> List[concurrent_task_utils.TaskResult]: linter_stdout.append(self.check_skip_files_in_app_dev_yaml()) linter_stdout.append(self.check_third_party_libs_type_defs()) linter_stdout.append(self.check_github_workflows_have_name()) + linter_stdout.append( + self.check_duplicate_method_names_in_user_utilities() + ) return linter_stdout diff --git a/scripts/linters/other_files_linter_test.py b/scripts/linters/other_files_linter_test.py index 3de13a5877e8e..050e0996a12f0 100644 --- a/scripts/linters/other_files_linter_test.py +++ b/scripts/linters/other_files_linter_test.py @@ -400,6 +400,196 @@ def mock_read(path: str) -> str: ).check_github_workflows_have_name() self.assertEqual(task_results.get_report(), expected) + def test_check_duplicate_method_names_in_user_utilities_no_duplicates( + self, + ) -> None: + def mock_listdir(unused_path: str) -> List[str]: + return ['exploration-editor.ts', 'logged-in-user.ts'] + + def mock_read(path: str) -> str: + if path.endswith('exploration-editor.ts'): + return '\n'.join( + [ + 'export class ExplorationEditor extends BaseUser {', + ' async addHint(): Promise {}', + '}', + ] + ) + elif path.endswith('logged-in-user.ts'): + return '\n'.join( + [ + 'export class LoggedInUser extends BaseUser {', + ' async expectToBeOnPage(): Promise {}', + '}', + ] + ) + raise AssertionError( + 'mock_read called with unexpected path %s' % path + ) + + listdir_swap = self.swap_with_checks( + os, + 'listdir', + mock_listdir, + expected_args=[(other_files_linter.PLAYWRIGHT_USER_UTILITIES_DIR,)], + ) + read_swap = self.swap(FILE_CACHE, 'read', mock_read) + + expected = [ + 'SUCCESS Duplicate method names in user utilities check passed' + ] + + with listdir_swap, read_swap: + task_results = other_files_linter.CustomLintChecksManager( + FILE_CACHE + ).check_duplicate_method_names_in_user_utilities() + self.assertEqual(task_results.get_report(), expected) + self.assertFalse(task_results.failed) + + def test_check_duplicate_method_names_in_user_utilities_with_duplicates( + self, + ) -> None: + def mock_listdir(unused_path: str) -> List[str]: + return ['exploration-editor.ts', 'logged-out-user.ts'] + + def mock_read(path: str) -> str: + if path.endswith('exploration-editor.ts'): + return '\n'.join( + [ + 'export class ExplorationEditor extends BaseUser {', + ' async continueToNextCard(): Promise {}', + '}', + ] + ) + elif path.endswith('logged-out-user.ts'): + return '\n'.join( + [ + 'export class LoggedOutUser extends BaseUser {', + ' async continueToNextCard(): Promise {}', + '}', + ] + ) + raise AssertionError( + 'mock_read called with unexpected path %s' % path + ) + + listdir_swap = self.swap_with_checks( + os, + 'listdir', + mock_listdir, + expected_args=[(other_files_linter.PLAYWRIGHT_USER_UTILITIES_DIR,)], + ) + read_swap = self.swap(FILE_CACHE, 'read', mock_read) + + expected = [ + 'Method "continueToNextCard" is defined in multiple user ' + 'utility files: exploration-editor.ts, logged-out-user.ts. ' + 'Rename to disambiguate, following the convention ' + '{action}In{PageContext}Page.', + 'FAILED Duplicate method names in user utilities check failed', + ] + + with listdir_swap, read_swap: + task_results = other_files_linter.CustomLintChecksManager( + FILE_CACHE + ).check_duplicate_method_names_in_user_utilities() + self.assertEqual(task_results.get_report(), expected) + self.assertTrue(task_results.failed) + + def test_check_duplicate_method_names_in_user_utilities_ignores_non_ts( + self, + ) -> None: + """Non-.ts files in the directory (e.g. the duplicate-functions + report itself) must not be scanned for method names. + """ + + def mock_listdir(unused_path: str) -> List[str]: + return ['exploration-editor.ts', 'duplicate-functions.md'] + + def mock_read(path: str) -> str: + if path.endswith('exploration-editor.ts'): + return '\n'.join( + [ + 'export class ExplorationEditor extends BaseUser {', + ' async addHint(): Promise {}', + '}', + ] + ) + raise AssertionError( + 'mock_read called with unexpected path %s' % path + ) + + listdir_swap = self.swap_with_checks( + os, + 'listdir', + mock_listdir, + expected_args=[(other_files_linter.PLAYWRIGHT_USER_UTILITIES_DIR,)], + ) + read_swap = self.swap(FILE_CACHE, 'read', mock_read) + + expected = [ + 'SUCCESS Duplicate method names in user utilities check passed' + ] + + with listdir_swap, read_swap: + task_results = other_files_linter.CustomLintChecksManager( + FILE_CACHE + ).check_duplicate_method_names_in_user_utilities() + self.assertEqual(task_results.get_report(), expected) + self.assertFalse(task_results.failed) + + def test_check_duplicate_method_names_flags_private_methods_too( + self, + ) -> None: + """Edge case: two different classes with a same-named private + method should still be flagged, since the check is name-based + across files rather than access-modifier-aware. + """ + + def mock_listdir(unused_path: str) -> List[str]: + return ['curriculum-admin.ts', 'topic-manager.ts'] + + def mock_read(path: str) -> str: + if path.endswith('curriculum-admin.ts'): + return '\n'.join( + [ + 'export class CurriculumAdmin extends BaseUser {', + ' private async waitForSave(): Promise {}', + '}', + ] + ) + elif path.endswith('topic-manager.ts'): + return '\n'.join( + [ + 'export class TopicManager extends BaseUser {', + ' private async waitForSave(): Promise {}', + '}', + ] + ) + raise AssertionError( + 'mock_read called with unexpected path %s' % path + ) + + listdir_swap = self.swap_with_checks( + os, + 'listdir', + mock_listdir, + expected_args=[(other_files_linter.PLAYWRIGHT_USER_UTILITIES_DIR,)], + ) + read_swap = self.swap(FILE_CACHE, 'read', mock_read) + + with listdir_swap, read_swap: + task_results = other_files_linter.CustomLintChecksManager( + FILE_CACHE + ).check_duplicate_method_names_in_user_utilities() + self.assertTrue(task_results.failed) + self.assertTrue( + any( + 'waitForSave' in message + for message in task_results.get_report() + ) + ) + def test_perform_all_lint_checks(self) -> None: lint_task_report = other_files_linter.CustomLintChecksManager( FILE_CACHE