diff --git a/assets/constants.ts b/assets/constants.ts index d112708d036ee..cdf93f1131b56 100644 --- a/assets/constants.ts +++ b/assets/constants.ts @@ -6074,6 +6074,19 @@ export default { // eslint-disable-next-line max-len "Refer to Glossary - https://docs.google.com/spreadsheets/d/13NMEnYqLZuMbeX1Z6XXG-femHkKNAN8KwjhaC67EkxI/edit#gid=0" ], + // Hinglish. + "hi-en": [ + // eslint-disable-next-line max-len + "For mathematical terms in Hinglish, use the English words (e.g. numerator, denominator) instead of their Hindi equivalents (e.g. Bhinn, Ansh).", + // eslint-disable-next-line max-len + "Use respectful pronouns (like “Aap” instead of “Tum/Tu”) and a corresponding respectful tone like “kariye, karenge”.", + // eslint-disable-next-line max-len + "Feel free to change the voice and sequence of phrases to make the sentence more readable.", + // eslint-disable-next-line max-len + "Preserve punctuation and bolding. If the original content has bold text, make sure it is bold in Hinglish as well. If there are bullet points, double quotes, etc., make sure that the translated content also has bullet points and double quotes.", + // eslint-disable-next-line max-len + "If the original card has “components” (such as pictures, links, and equations), these need to be added to the translated content. You can use the “Copy tool” for this -- click on the Copy tool and then click on the component you want to carry over. Also, double-click on the image and translate the alt text (and caption, if any)." + ], // Spanish. "es": [ "Include proper punctuation, ¡blank!, ¿question? and accent marks.", diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 58ca96d94e51c..f10536b2d1636 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -199,6 +199,12 @@ "I18N_ASSESSMENT_INTERRUPT_SESSION_ENDED": "Your assessment session ended before it was submitted.", "I18N_ASSESSMENT_INTERRUPT_TITLE": "Assessment Interrupted", "I18N_ASSESSMENT_START_BUTTON": "Start Assessment", + "I18N_ASSESSMENT_UNAVAILABLE_ALERTED_CREATOR_MESSAGE": "We've alerted the creator team about this issue.", + "I18N_ASSESSMENT_UNAVAILABLE_GO_TO_CERTIFICATES_BUTTON": "Go to available certificates", + "I18N_ASSESSMENT_UNAVAILABLE_MESSAGE": "Sorry, this assessment isn't available.", + "I18N_ASSESSMENT_UNAVAILABLE_NOTE_TEXT": "Because this assessment is currently unavailable, you cannot start or submit this assessment.", + "I18N_ASSESSMENT_UNAVAILABLE_TITLE": "Assessment Unavailable", + "I18N_ASSESSMENT_UNAVAILABLE_TRY_OTHER_MESSAGE": "In the meantime, you can try a different assessment from the available certificates section.", "I18N_ASSIGNED_STORIES_AND_SKILLS": "Assigned Stories and Skills", "I18N_ASSIGNED_STORIES_AND_SKILLS_EMPTY_MESSAGE": "No stories or skills have been assigned to learners in this group.", "I18N_ATTRIBUTION_HTML_STEP_ONE": "Copy and paste the HTML", @@ -277,6 +283,7 @@ "I18N_CERTIFICATE_ASSESSMENT_CONVERSATION_SKIN_CURRENT_PROGRESS": "Current Progress: <[progressPercentage]>%", "I18N_CERTIFICATE_ASSESSMENT_CONVERSATION_SKIN_NEXT_BUTTON": "Next", "I18N_CERTIFICATE_ASSESSMENT_CONVERSATION_SKIN_SUBMIT_ASSESSMENT_BUTTON": "Submit Assessment", + "I18N_CERTIFICATE_ASSESSMENT_COOLDOWN_ERROR": "You just started an assessment before this time. Please try again in <[remainingMinutes]> minute(s).", "I18N_CERTIFICATE_ASSESSMENT_DEMONSTRATES_HEADING": "What this certificate demonstrates", "I18N_CERTIFICATE_ASSESSMENT_EXIT_BUTTON": "Exit", "I18N_CERTIFICATE_ASSESSMENT_RESULT_FAILED_HEADING": "Don't give up! You can try again", @@ -1681,18 +1688,35 @@ "I18N_TOPIC_LANDING_PAGE_TITLE": "<[topicTitle]> | <[topicTagline]> | Oppia", "I18N_TOPIC_LEARN": "Learn", "I18N_TOPIC_TITLE": "Topic", + "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_ALL_COMPLETE_SUBTITLE": "You've mastered all available adventures. Keep the momentum going!", + "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_CONTINUE_BUTTON": "Continue to Topic", + "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_MOMENTUM_SUBTITLE": "Keep the momentum going!", + "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_NUMBER_TITLE": "Adventure <[adventureNumber]> Mastered!", + "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_TITLE": "Adventure Mastered!", + "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_UNLOCKED_SUBTITLE": "You've unlocked Adventure <[adventureNumber]>. Keep the momentum going!", + "I18N_TOPIC_VIEWER_ADVENTURE_NUMBER_LABEL": "Adventure <[adventureNumber]>", + "I18N_TOPIC_VIEWER_ADVENTURE_RESUME_BUTTON": "Resume", + "I18N_TOPIC_VIEWER_ADVENTURE_START_BUTTON": "Start", + "I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_MESSAGE": "{count, plural, one{Adventure <[adventureNumbers]> will be marked as skipped, but you can return to it at any time.} other{Adventures <[adventureNumbers]> will be marked as skipped, but you can return to them at any time.}}", + "I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_TITLE": "Skip to <[adventureLabel]>?", "I18N_TOPIC_VIEWER_CHAPTER": "Chapter", "I18N_TOPIC_VIEWER_CHAPTERS": "{count, plural, one{1 chapter} other{# chapters}}", + "I18N_TOPIC_VIEWER_CLOSE_BUTTON": "Close", "I18N_TOPIC_VIEWER_COMING_SOON": "Coming Soon!", - "I18N_TOPIC_VIEWER_COMING_SOON_CHAPTERS": "COMING SOON CHAPTERS", + "I18N_TOPIC_VIEWER_COMING_SOON_CHAPTERS_ARIA_LABEL": "Coming soon chapters", + "I18N_TOPIC_VIEWER_COMING_SOON_CHAPTERS_HEADING": "COMING SOON CHAPTERS", "I18N_TOPIC_VIEWER_COMING_SOON_LESSONS": "Come back later when lessons are available for this topic.", "I18N_TOPIC_VIEWER_COMING_SOON_PRACTICE": "Come back later when practice questions are available for this topic.", "I18N_TOPIC_VIEWER_LEARN": "Learn", "I18N_TOPIC_VIEWER_LESSON_COMING_SOON_LABEL": "COMING SOON", + "I18N_TOPIC_VIEWER_LESSON_COMPLETED_LABEL": "Completed", "I18N_TOPIC_VIEWER_LESSON_NEW_LABEL": "NEW", + "I18N_TOPIC_VIEWER_LESSON_PLAY_AGAIN_BUTTON": "Play Again", "I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_BUTTON": "Mastery Challenge", "I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_DESCRIPTION": "Test your knowledge with a challenge that covers everything you have learned in this topic.", "I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_TITLE": "Mastery Challenge", + "I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_TOOLTIP_DESCRIPTION": "Finish all chapters in order to unlock the Mastery Challenge and prove your mastery of this topic.", + "I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_TOOLTIP_TITLE": "Complete all chapters to unlock", "I18N_TOPIC_VIEWER_MASTER_SKILLS": "Master Skills for <[topicName]>", "I18N_TOPIC_VIEWER_NAVIGATION_HINT": "Click to navigate through lessons and tests", "I18N_TOPIC_VIEWER_NO_QUESTION_WARNING": "There are no questions created yet for the selected subtopic(s).", @@ -1705,6 +1729,9 @@ "I18N_TOPIC_VIEWER_PRACTICE_SESSION_BETA_TAG": "Beta", "I18N_TOPIC_VIEWER_PRACTICE_TEST_BUTTON": "Practice Test", "I18N_TOPIC_VIEWER_SELECT_SKILLS": "Select the skills from the <[topicName]> lessons you would like to practice.", + "I18N_TOPIC_VIEWER_SKIPPED_ADVENTURE_ARIA_LABEL": "<[adventureTitle]> was skipped. Expand to review.", + "I18N_TOPIC_VIEWER_SKIPPED_ADVENTURE_BADGE": "SKIPPED", + "I18N_TOPIC_VIEWER_SKIPPED_ADVENTURE_MESSAGE": "This adventure was skipped. You can revisit it anytime.", "I18N_TOPIC_VIEWER_START_PRACTICE": "Start", "I18N_TOPIC_VIEWER_STORIES_HEAD": "Stories you can play", "I18N_TOPIC_VIEWER_STUDY": "Study", @@ -1767,7 +1794,7 @@ "I18N_TOTAL_SUBSCRIBERS_TEXT": "You have a total of <[totalSubscribers]> subscribers.", "I18N_UNANSWERED_QUESTION_MODAL_CLOSE_BUTTON": "Close", "I18N_UNANSWERED_QUESTION_MODAL_GO_BACK_BUTTON_ALT": "Go back to assessment", - "I18N_UNANSWERED_QUESTION_MODAL_MESSAGE": "You have {{unansweredQuestionCount}} unanswered questions. Unanswered questions will be marked incorrect.", + "I18N_UNANSWERED_QUESTION_MODAL_MESSAGE": "You have <[unansweredQuestionCount]> unanswered questions. Unanswered questions will be marked incorrect.", "I18N_UNANSWERED_QUESTION_MODAL_PRESS_PROMPT_AFTER": "to go back to the assessment or submit anyway.", "I18N_UNANSWERED_QUESTION_MODAL_PRESS_PROMPT_BEFORE": "Press", "I18N_UNANSWERED_QUESTION_MODAL_SUBMIT_BUTTON": "Submit Assessment", diff --git a/assets/i18n/qqq.json b/assets/i18n/qqq.json index 48a55d2cfce00..2e7af694db15c 100644 --- a/assets/i18n/qqq.json +++ b/assets/i18n/qqq.json @@ -199,6 +199,12 @@ "I18N_ASSESSMENT_INTERRUPT_SESSION_ENDED": "Message informing the user that their assessment session ended before it was submitted.", "I18N_ASSESSMENT_INTERRUPT_TITLE": "Title displayed on the assessment interruption card when a certificate assessment session is interrupted.", "I18N_ASSESSMENT_START_BUTTON": "Label for the button that starts the assessment.", + "I18N_ASSESSMENT_UNAVAILABLE_ALERTED_CREATOR_MESSAGE": "Message informing the user that the creator team has been alerted about the unavailable assessment.", + "I18N_ASSESSMENT_UNAVAILABLE_GO_TO_CERTIFICATES_BUTTON": "Text on the button that navigates the user to the available certificates section.", + "I18N_ASSESSMENT_UNAVAILABLE_MESSAGE": "Message informing the user that the assessment is not available.", + "I18N_ASSESSMENT_UNAVAILABLE_NOTE_TEXT": "Note explaining that the assessment cannot be started or submitted because it is currently unavailable.", + "I18N_ASSESSMENT_UNAVAILABLE_TITLE": "Title displayed on the modal shown when a certificate assessment is unavailable.", + "I18N_ASSESSMENT_UNAVAILABLE_TRY_OTHER_MESSAGE": "Message encouraging the user to try a different assessment from the available certificates section.", "I18N_ASSIGNED_STORIES_AND_SKILLS": "Syllabus tab title on the edit learner group page.", "I18N_ASSIGNED_STORIES_AND_SKILLS_EMPTY_MESSAGE": "Text displayed to user when no skills or stories are assigned to the learner under group syllabus.", "I18N_ATTRIBUTION_HTML_STEP_ONE": "Step one of attributing Oppia in HTML", @@ -277,6 +283,7 @@ "I18N_CERTIFICATE_ASSESSMENT_CONVERSATION_SKIN_CURRENT_PROGRESS": "Text showing the learner's progress through the certificate assessment questions.", "I18N_CERTIFICATE_ASSESSMENT_CONVERSATION_SKIN_NEXT_BUTTON": "Label for the next button in the certificate assessment question view.", "I18N_CERTIFICATE_ASSESSMENT_CONVERSATION_SKIN_SUBMIT_ASSESSMENT_BUTTON": "Label for the button that submits the certificate assessment.", + "I18N_CERTIFICATE_ASSESSMENT_COOLDOWN_ERROR": "Warning message shown when the learner tries to start a new certificate assessment attempt before the cooldown window after their previous attempt has elapsed. <[remainingMinutes]> is replaced with the whole number of minutes they must wait; it is always at least one.", "I18N_CERTIFICATE_ASSESSMENT_DEMONSTRATES_HEADING": "Heading displayed on the certificate introduction card that explains what knowledge and skills the certificate demonstrates.", "I18N_CERTIFICATE_ASSESSMENT_EXIT_BUTTON": "Text displayed on the exit button in the certificate assessment header. Clicking the button exits the certificate assessment flow.", "I18N_CERTIFICATE_ASSESSMENT_RESULT_FAILED_HEADING": "Heading text shown when a learner does not pass the certificate assessment result card.", @@ -1681,18 +1688,35 @@ "I18N_TOPIC_LANDING_PAGE_TITLE": "Text displayed on the browser's tab when on the topic landing page.", "I18N_TOPIC_LEARN": "The title of the column in the learner dashboard that allows learner to mark a topic as a goal.\n{{Identical|Learn}}", "I18N_TOPIC_TITLE": "The title of the column in the learner dashboard that displays topic names.\n\n{{Identical|Topic}}", + "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_ALL_COMPLETE_SUBTITLE": "Subtitle shown in the adventure mastered modal when the learner has completed every available adventure.", + "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_CONTINUE_BUTTON": "Label for the button that closes the adventure mastered modal and returns the learner to the topic page.", + "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_MOMENTUM_SUBTITLE": "Subtitle shown in the adventure mastered modal when no specific adventure was just mastered.", + "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_NUMBER_TITLE": "Title shown in the adventure mastered modal, where <[adventureNumber]> is the 1-based position of the adventure the learner just mastered.", + "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_TITLE": "Title shown in the adventure mastered modal when no specific adventure was just mastered.", + "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_UNLOCKED_SUBTITLE": "Subtitle shown in the adventure mastered modal when mastering the adventure unlocked the next one, where <[adventureNumber]> is the 1-based position of the unlocked adventure.", + "I18N_TOPIC_VIEWER_ADVENTURE_NUMBER_LABEL": "Label used to refer to an adventure by its 1-based position, where <[adventureNumber]> is the position of the adventure.", + "I18N_TOPIC_VIEWER_ADVENTURE_RESUME_BUTTON": "Label for the button that resumes a previously started skipped adventure.", + "I18N_TOPIC_VIEWER_ADVENTURE_START_BUTTON": "Label for the button that starts a skipped adventure that was never started.", + "I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_MESSAGE": "Message shown in the confirmation modal when the learner tries to navigate to a later adventure while skipping earlier unfinished ones, where <[adventureNumbers]> is the human-readable list of skipped adventure positions (for example '2', '1 and 2', or '1, 2, and 3').\n\nOnly translate the words inside the 'one' and 'other' branches (keeping the '<[adventureNumbers]>' placeholder and the '{count, plural, ...}' syntax unchanged).", + "I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_TITLE": "Title shown in the confirmation modal when the learner tries to skip to a later adventure, where <[adventureLabel]> is the label of the target adventure (for example 'Adventure 3').", "I18N_TOPIC_VIEWER_CHAPTER": "Text to be displayed on each story summary tile in the topic viewer indicating the chapter count in the story.", "I18N_TOPIC_VIEWER_CHAPTERS": "Text to be displayed on each topic summary tile with singular/plural adjustments.\n\nOnly translate \"1 chapter\" and \"# chapters\" (keep a \"#\" as the placeholder for the actual replaced number). The rest should remain as is, including curly braces (\"{\", \"}\"), plural keywords \"count\", \"one\" or \"other\" (in English), and comma separators (\",\").\n\nIn languages that don't need to mark different forms for the plural, you may just keep the \"other\" option (used as the default value for the result of the \"{count...}\" expression), and then translate just \"# chapters\".", + "I18N_TOPIC_VIEWER_CLOSE_BUTTON": "ARIA label for the close button that dismisses a dialog.\n{{Identical|Close}}", "I18N_TOPIC_VIEWER_COMING_SOON": "Text displayed as a heading when no content is available to show in respective tabs.", - "I18N_TOPIC_VIEWER_COMING_SOON_CHAPTERS": "Heading displayed above the list of chapters that are not yet published and cannot be accessed by the learner.", + "I18N_TOPIC_VIEWER_COMING_SOON_CHAPTERS_ARIA_LABEL": "ARIA label for the section listing chapters that are not yet available.", + "I18N_TOPIC_VIEWER_COMING_SOON_CHAPTERS_HEADING": "Heading shown above the list of chapters that are not yet available.", "I18N_TOPIC_VIEWER_COMING_SOON_LESSONS": "Text displayed as a descriptive message when no content is available to show in lessons tab.", "I18N_TOPIC_VIEWER_COMING_SOON_PRACTICE": "Text displayed as a descriptive message when no content is available to show in Practice tab.", "I18N_TOPIC_VIEWER_LEARN": "Title shown in a tab header in the topic's viewer page. - Clicking on this shows a list of the lessons that lie within the topic", "I18N_TOPIC_VIEWER_LESSON_COMING_SOON_LABEL": "Label badge displayed on lesson cards that are coming soon.", + "I18N_TOPIC_VIEWER_LESSON_COMPLETED_LABEL": "Label badge displayed on lesson cards that have been completed.", "I18N_TOPIC_VIEWER_LESSON_NEW_LABEL": "Label badge displayed on new lesson cards.", + "I18N_TOPIC_VIEWER_LESSON_PLAY_AGAIN_BUTTON": "Label for the button that lets a learner replay a completed lesson.", "I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_BUTTON": "Label for the mastery challenge button that navigates to the challenge.", "I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_DESCRIPTION": "Description for the mastery challenge card explaining what the challenge covers.", "I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_TITLE": "Title for the mastery challenge card at the end of a story section.", + "I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_TOOLTIP_DESCRIPTION": "Description text for helper tooltip shown when the learner clicks mastery challenge before completing all chapters.", + "I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_TOOLTIP_TITLE": "Heading text for helper tooltip shown when mastery challenge is still locked.", "I18N_TOPIC_VIEWER_MASTER_SKILLS": "Title shown in the 'Practice' tab in the topic viewer, where the user can select skills to tackle questions on them.", "I18N_TOPIC_VIEWER_NAVIGATION_HINT": "Hint text below the adventure navigation to guide users on how to navigate.", "I18N_TOPIC_VIEWER_NO_QUESTION_WARNING": "Text displayed below the start button in 'practice' tab, when user select a subtopic which does not contain any questions attached to it.", @@ -1705,6 +1729,9 @@ "I18N_TOPIC_VIEWER_PRACTICE_SESSION_BETA_TAG": "Text enclosed in parentheses meant to indicate that a practice session is in beta", "I18N_TOPIC_VIEWER_PRACTICE_TEST_BUTTON": "Label for the practice test button in the adventure end test card.", "I18N_TOPIC_VIEWER_SELECT_SKILLS": "Subtitle shown in the 'Practice' tab in the topic viewer, where the user can select skills to tackle questions on them.", + "I18N_TOPIC_VIEWER_SKIPPED_ADVENTURE_ARIA_LABEL": "ARIA label for a card representing an adventure the learner skipped, where <[adventureTitle]> is the title of the skipped adventure.", + "I18N_TOPIC_VIEWER_SKIPPED_ADVENTURE_BADGE": "Badge text displayed on cards of adventures that the learner skipped.", + "I18N_TOPIC_VIEWER_SKIPPED_ADVENTURE_MESSAGE": "Message displayed on cards of adventures that the learner skipped, reassuring them that they can revisit the adventure later.", "I18N_TOPIC_VIEWER_START_PRACTICE": "Text displayed on the start button in the 'Practice' tab in the topic viewer. Clicking this, the user can start a practice session on skills selected by the user.\n{{Identical|Start}}", "I18N_TOPIC_VIEWER_STORIES_HEAD": "Text displayed above all stories specifically in mobile view.", "I18N_TOPIC_VIEWER_STUDY": "Title shown in a tab header in the topic's viewer page. - Clicking on this shows a list of the study guides that lie within the topic", diff --git a/core/controllers/access_validators.py b/core/controllers/access_validators.py index b3aedf43d35bd..4c6ea3983a8f2 100644 --- a/core/controllers/access_validators.py +++ b/core/controllers/access_validators.py @@ -421,19 +421,19 @@ def _validate_node_id( ) -> None: """Validates that the given node ID exists in the first story. - The node_id parameter is a 1-based index that maps to the nth node - in the first published story of the topic. + The node_id parameter maps to a node by its ID suffix (e.g., '1' + maps to the node whose id is 'node_1'). Args: topic: Topic. The topic object. - node_id: str. The node ID (1-based index) to validate. + node_id: str. The node ID suffix (e.g., '1') to validate. Raises: NotFoundException. The node ID was not found. """ all_nodes = self._get_all_nodes_for_topic(topic) - valid_indices = {node.id.replace('node_', '') for node in all_nodes} - if node_id not in valid_indices: + target_node_id = 'node_%s' % node_id + if not any(node.id == target_node_id for node in all_nodes): raise self.NotFoundException( 'Node with id %s is not part of this topic.' % node_id ) @@ -441,19 +441,23 @@ def _validate_node_id( def _validate_arc_id(self, topic: topic_domain.Topic, arc_id: str) -> None: """Validates that the given arc ID exists in the first story. - The arc_id parameter is a 1-based index that maps to the nth arc - in the first published story of the topic. + The arc_id parameter is a 1-based index that maps to the nth arc in + the first published story of the topic (e.g., '1' maps to the first + arc). Args: topic: Topic. The topic object. - arc_id: str. The arc index (e.g., '1') to validate. + arc_id: str. The arc ID (1-based index) to validate. Raises: NotFoundException. The arc ID was not found. """ all_arcs = self._get_all_arcs_for_topic(topic) - valid_indices = {arc.id.replace('arc_', '') for arc in all_arcs} - if arc_id not in valid_indices: + if ( + not arc_id.isascii() + or not arc_id.isdigit() + or not 1 <= int(arc_id) <= len(all_arcs) + ): raise self.NotFoundException( 'Arc with id %s is not part of this topic.' % arc_id ) diff --git a/core/controllers/access_validators_test.py b/core/controllers/access_validators_test.py index fefa8096ff1a1..d60f02d5283a6 100644 --- a/core/controllers/access_validators_test.py +++ b/core/controllers/access_validators_test.py @@ -668,6 +668,97 @@ def test_any_user_can_access_end_of_arc_page(self) -> None: expected_status_int=200, ) + def test_any_user_can_access_end_of_arc_page_with_arc_position( + self, + ) -> None: + story_id = 'story_id_2' + exp_id = 'exp_2' + self.save_new_valid_exploration(exp_id, self.admin_id) + self.publish_exploration(self.admin_id, exp_id) + self.save_new_story(story_id, self.admin_id, self.topic_id) + topic_services.add_canonical_story( + self.admin_id, self.topic_id, story_id + ) + story_services.update_story( + self.admin_id, + story_id, + [ + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_ADD_STORY_NODE, + 'node_id': 'node_1', + 'title': 'Chapter 1', + } + ), + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY, + 'property_name': ( + story_domain.STORY_NODE_PROPERTY_EXPLORATION_ID + ), + 'node_id': 'node_1', + 'old_value': None, + 'new_value': exp_id, + } + ), + ], + 'Added node.', + ) + topic_services.publish_story(self.topic_id, story_id, self.admin_id) + story_services.update_story( + self.admin_id, + story_id, + [ + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY, + 'property_name': ( + story_domain.STORY_NODE_PROPERTY_ACQUIRED_SKILL_IDS + ), + 'node_id': 'node_1', + # Here we use cast because the empty list's type cannot + # be inferred, and List[str] is needed to match + # AcceptableChangeDictTypes. + 'old_value': cast(List[str], []), + 'new_value': [self.skill_id1], + } + ), + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_CREATE_ARC, + 'arc_id': 'default_arc', + 'title': 'Default Arc', + 'description': 'Default arc', + 'node_ids': ['node_1'], + } + ), + ], + 'Added acquired skill IDs and arc.', + ) + + self.get_html_response( + '%s/can_access_practice_session_page/%s/%s/test/arc/%s' + % ( + ACCESS_VALIDATION_HANDLER_PREFIX, + 'math', + 'public-topic-name', + '1', + ), + expected_status_int=200, + ) + + def test_end_of_arc_page_with_out_of_range_arc_position(self) -> None: + self.get_html_response( + '%s/can_access_practice_session_page/%s/%s/test/arc/%s' + % ( + ACCESS_VALIDATION_HANDLER_PREFIX, + 'math', + 'public-topic-name', + '999', + ), + expected_status_int=404, + ) + def test_lesson_practice_with_invalid_node_id(self) -> None: self.get_html_response( '%s/can_access_practice_session_page/%s/%s/practice/%s' @@ -692,6 +783,30 @@ def test_end_of_arc_page_with_invalid_arc_id(self) -> None: expected_status_int=404, ) + def test_end_of_arc_page_with_non_ascii_arc_id(self) -> None: + self.get_html_response( + '%s/can_access_practice_session_page/%s/%s/test/arc/%s' + % ( + ACCESS_VALIDATION_HANDLER_PREFIX, + 'math', + 'public-topic-name', + '%D9%A1', + ), + expected_status_int=404, + ) + + def test_end_of_arc_page_with_emoji_arc_id(self) -> None: + self.get_html_response( + '%s/can_access_practice_session_page/%s/%s/test/arc/%s' + % ( + ACCESS_VALIDATION_HANDLER_PREFIX, + 'math', + 'public-topic-name', + '%F0%9F%98%80', + ), + expected_status_int=404, + ) + def test_lesson_practice_with_story_that_does_not_exist(self) -> None: story_id = 'story_id' deleted_story_id = 'del_story' diff --git a/core/controllers/certificate_assessment.py b/core/controllers/certificate_assessment.py index b25b5ad0cdffc..fc05f08a08159 100644 --- a/core/controllers/certificate_assessment.py +++ b/core/controllers/certificate_assessment.py @@ -456,6 +456,21 @@ def post(self) -> None: self.normalized_payload['certificate_id'], self.user_id ) ) + except ( + certificate_assessment_services.CertificateAssessmentAttemptCooldownException + ) as e: + # Surface a structured error so the frontend can translate it via + # its translate pipes, instead of a hardcoded English string. + self.error(429) + self.render_json( + { + 'error': ('I18N_CERTIFICATE_ASSESSMENT_COOLDOWN_ERROR'), + 'error_type': 'cooldown', + 'remaining_minutes': e.remaining_minutes, + 'status_code': 429, + } + ) + return except utils.ValidationError as e: raise self.InvalidInputException(e) from e except ( diff --git a/core/controllers/certificate_assessment_test.py b/core/controllers/certificate_assessment_test.py index fc8b5c4fd631e..beaca1ea2925a 100644 --- a/core/controllers/certificate_assessment_test.py +++ b/core/controllers/certificate_assessment_test.py @@ -949,6 +949,41 @@ def test_post_raises_invalid_input_on_invalidated_offering(self) -> None: ): handler.post() + def test_post_returns_cooldown_error_with_remaining_minutes(self) -> None: + handler = ( + certificate_assessment.StartCertificateAssessmentHandler.__new__( + certificate_assessment.StartCertificateAssessmentHandler + ) + ) + handler.user_id = 'user_id_1' + handler.normalized_payload = {'certificate_id': 'cert_1'} + with mock.patch.object( + certificate_assessment_services, + 'start_certificate_assessment_attempt', + side_effect=( + certificate_assessment_services.CertificateAssessmentAttemptCooldownException( + 6 + ) + ), + ), mock.patch.object( + certificate_assessment.StartCertificateAssessmentHandler, + 'error', + ) as error_mock, mock.patch.object( + certificate_assessment.StartCertificateAssessmentHandler, + 'render_json', + ) as render_json_mock: + handler.post() + + error_mock.assert_called_once_with(429) + render_json_mock.assert_called_once_with( + { + 'error': 'I18N_CERTIFICATE_ASSESSMENT_COOLDOWN_ERROR', + 'error_type': 'cooldown', + 'remaining_minutes': 6, + 'status_code': 429, + } + ) + class SubmitCertificateAssessmentHandlerUnitTests(test_utils.GenericTestBase): """Tests for the submit certificate assessment handler.""" diff --git a/core/controllers/concept_card_viewer.py b/core/controllers/concept_card_viewer.py index 5edfca5dc4fc7..bc7a66dbf3b3b 100644 --- a/core/controllers/concept_card_viewer.py +++ b/core/controllers/concept_card_viewer.py @@ -18,12 +18,24 @@ from core import feconf from core.controllers import acl_decorators, base -from core.domain import skill_fetchers +from core.domain import skill_fetchers, skill_services -from typing import Dict, List +from typing import Dict, List, Optional, TypedDict -class ConceptCardDataHandler(base.BaseHandler[Dict[str, str], Dict[str, str]]): +class ConceptCardDataHandlerNormalizedRequestDict(TypedDict): + """Dict representation of ConceptCardDataHandler's GET normalized + request dictionary. + """ + + language_code: Optional[str] + + +class ConceptCardDataHandler( + base.BaseHandler[ + Dict[str, str], ConceptCardDataHandlerNormalizedRequestDict + ] +): """A card that shows the explanation of a skill's concept.""" GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON @@ -32,7 +44,17 @@ class ConceptCardDataHandler(base.BaseHandler[Dict[str, str], Dict[str, str]]): 'schema': {'type': 'custom', 'obj_type': 'JsonEncodedInString'} } } - HANDLER_ARGS_SCHEMAS: Dict[str, Dict[str, str]] = {'GET': {}} + HANDLER_ARGS_SCHEMAS = { + 'GET': { + 'language_code': { + 'schema': { + 'type': 'basestring', + 'validators': [{'id': 'is_supported_audio_language_code'}], + }, + 'default_value': None, + } + } + } @acl_decorators.can_view_skills def get(self, selected_skill_ids: List[str]) -> None: @@ -41,12 +63,14 @@ def get(self, selected_skill_ids: List[str]) -> None: Args: selected_skill_ids: list(str). List of skill ids. """ + assert self.normalized_request is not None + language_code = self.normalized_request.get('language_code') skills = skill_fetchers.get_multi_skills(selected_skill_ids) - concept_card_dicts = [] - for skill in skills: - concept_card_dicts.append(skill.skill_contents.to_dict()) + concept_card_dicts = skill_services.get_concept_card_dicts( + skills, language_code + ) self.values.update({'concept_card_dicts': concept_card_dicts}) diff --git a/core/controllers/concept_card_viewer_test.py b/core/controllers/concept_card_viewer_test.py index 9d70c25ae7122..4de79d9da4fc0 100644 --- a/core/controllers/concept_card_viewer_test.py +++ b/core/controllers/concept_card_viewer_test.py @@ -21,9 +21,11 @@ from core import feconf from core.domain import ( skill_domain, + skill_fetchers, skill_services, state_domain, translation_domain, + translation_services, user_services, ) from core.tests import test_utils @@ -128,6 +130,169 @@ def test_get_concept_cards(self) -> None: json_response['concept_card_dicts'][1]['explanation']['html'], ) + def _add_explanation_translation( + self, + skill_id: str, + language_code: str, + translation_html: str, + needs_update: bool = False, + ) -> None: + """Adds a translation of the skill's explanation. + + Args: + skill_id: str. The ID of the skill to translate. + language_code: str. The language to translate into. + translation_html: str. The translated explanation. + needs_update: bool. Whether the translation is stale. + """ + skill = skill_fetchers.get_skill_by_id(skill_id) + translation_services.add_new_translation( + feconf.TranslatableEntityType.SKILL, + skill_id, + skill.version, + language_code, + skill.skill_contents.explanation.content_id, + translation_domain.TranslatedContent( + translation_html, + translation_domain.TranslatableContentFormat.HTML, + needs_update, + ), + ) + + def test_get_concept_cards_in_a_translated_language(self) -> None: + self._add_explanation_translation( + self.skill_id, 'es', '

Explicación de la habilidad

' + ) + + json_response = self.get_json( + '%s/%s' + % ( + feconf.CONCEPT_CARD_DATA_URL_PREFIX, + json.dumps([self.skill_id, self.skill_id_1]), + ), + params={'language_code': 'es'}, + ) + + self.assertEqual( + '

Explicación de la habilidad

', + json_response['concept_card_dicts'][0]['explanation']['html'], + ) + # The second skill has no translation, so it stays in English. + self.assertEqual( + '

Skill Explanation 1

', + json_response['concept_card_dicts'][1]['explanation']['html'], + ) + + def test_get_concept_cards_translates_the_skill_description(self) -> None: + skill = skill_fetchers.get_skill_by_id(self.skill_id) + translation_services.add_new_translation( + feconf.TranslatableEntityType.SKILL, + self.skill_id, + skill.version, + 'es', + feconf.SKILL_DESCRIPTION_CONTENT_ID, + translation_domain.TranslatedContent( + 'Descripcion de la habilidad', + translation_domain.TranslatableContentFormat.UNICODE_STRING, + False, + ), + ) + + json_response = self.get_json( + '%s/%s' + % ( + feconf.CONCEPT_CARD_DATA_URL_PREFIX, + json.dumps([self.skill_id, self.skill_id_1]), + ), + params={'language_code': 'es'}, + ) + + self.assertEqual( + 'Descripcion de la habilidad', + json_response['concept_card_dicts'][0]['skill_description'], + ) + # The second skill has no translated description, so it stays in + # English. + self.assertEqual( + skill_fetchers.get_skill_by_id(self.skill_id_1).description, + json_response['concept_card_dicts'][1]['skill_description'], + ) + + def test_get_concept_cards_falls_back_to_english_when_untranslated( + self, + ) -> None: + json_response = self.get_json( + '%s/%s' + % ( + feconf.CONCEPT_CARD_DATA_URL_PREFIX, + json.dumps([self.skill_id]), + ), + params={'language_code': 'es'}, + ) + + self.assertEqual( + '

Skill Explanation

', + json_response['concept_card_dicts'][0]['explanation']['html'], + ) + + def test_get_concept_cards_ignores_a_stale_translation(self) -> None: + self._add_explanation_translation( + self.skill_id, + 'es', + '

Explicación desactualizada

', + needs_update=True, + ) + + json_response = self.get_json( + '%s/%s' + % ( + feconf.CONCEPT_CARD_DATA_URL_PREFIX, + json.dumps([self.skill_id]), + ), + params={'language_code': 'es'}, + ) + + # A translation flagged as needing an update is out of date with the + # English content, so English is shown instead. + self.assertEqual( + '

Skill Explanation

', + json_response['concept_card_dicts'][0]['explanation']['html'], + ) + + def test_get_concept_cards_in_english_returns_original_content( + self, + ) -> None: + self._add_explanation_translation( + self.skill_id, 'es', '

Explicación de la habilidad

' + ) + + json_response = self.get_json( + '%s/%s' + % ( + feconf.CONCEPT_CARD_DATA_URL_PREFIX, + json.dumps([self.skill_id]), + ), + params={'language_code': 'en'}, + ) + + self.assertEqual( + '

Skill Explanation

', + json_response['concept_card_dicts'][0]['explanation']['html'], + ) + + def test_get_concept_cards_with_invalid_language_code(self) -> None: + json_response = self.get_json( + '%s/%s' + % ( + feconf.CONCEPT_CARD_DATA_URL_PREFIX, + json.dumps([self.skill_id]), + ), + params={'language_code': 'invalid_language_code'}, + expected_status_int=400, + ) + + self.assertIn('language_code', json_response['error']) + def test_get_concept_cards_fails_when_skill_doesnt_exist(self) -> None: self.get_json( '%s/%s' diff --git a/core/controllers/library.py b/core/controllers/library.py index c430a7b7af2c2..938f8ebf3c4e0 100644 --- a/core/controllers/library.py +++ b/core/controllers/library.py @@ -35,12 +35,26 @@ summary_services.DisplayableCollectionSummaryDict, ] +# Schema for the language the returned exploration metadata is displayed in. +# This is the learner's site language, so it is restricted to the languages the +# site itself is available in. +DISPLAY_IN_LANGUAGE_CODE_SCHEMA = { + 'schema': { + 'type': 'basestring', + 'choices': [ + language['id'] for language in constants.SUPPORTED_SITE_LANGUAGES + ], + }, + 'default_value': None, +} + def get_matching_activity_dicts( query_string: str, categories: List[str], language_codes: List[str], search_offset: Optional[int], + display_in_language_code: Optional[str] = None, ) -> Tuple[Sequence[UnionSummaryDictType], Optional[int]]: """Given the details of a query and a search offset, returns a list of activity dicts that satisfy the query. @@ -60,6 +74,9 @@ def get_matching_activity_dicts( exploration search results, to start the search from. If None, collection search results are returned first before the explorations. + display_in_language_code: str or None. The language code to display + translated exploration metadata in. If None, the original metadata + is returned. Returns: tuple. A tuple consisting of two elements: @@ -95,7 +112,7 @@ def get_matching_activity_dicts( for ( exp_summary_dict ) in summary_services.get_displayable_exp_summary_dicts_matching_ids( # pylint: disable=line-too-long - exp_ids + exp_ids, display_in_language_code=display_in_language_code ): activity_list.append(exp_summary_dict) @@ -120,29 +137,50 @@ def get(self) -> None: self.redirect(feconf.LIBRARY_INDEX_URL, permanent=True) -class LibraryIndexHandler(base.BaseHandler[Dict[str, str], Dict[str, str]]): +class LibraryIndexHandlerNormalizedRequestDict(TypedDict): + """Dict representation of LibraryIndexHandler's + normalized_request dictionary. + """ + + display_in_language_code: Optional[str] + + +class LibraryIndexHandler( + base.BaseHandler[Dict[str, str], LibraryIndexHandlerNormalizedRequestDict] +): """Provides data for the default library index page.""" GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON URL_PATH_ARGS_SCHEMAS: Dict[str, str] = {} - HANDLER_ARGS_SCHEMAS: Dict[str, Dict[str, str]] = {'GET': {}} + HANDLER_ARGS_SCHEMAS = { + 'GET': { + 'display_in_language_code': DISPLAY_IN_LANGUAGE_CODE_SCHEMA, + } + } @acl_decorators.open_access def get(self) -> None: """Handles GET requests.""" + assert self.normalized_request is not None + display_in_language_code = self.normalized_request.get( + 'display_in_language_code' + ) # TODO(sll): Support index pages for other language codes. summary_dicts_by_category = summary_services.get_library_groups( - [constants.DEFAULT_LANGUAGE_CODE] + [constants.DEFAULT_LANGUAGE_CODE], + display_in_language_code=display_in_language_code, ) top_rated_activity_summary_dicts = ( summary_services.get_top_rated_exploration_summary_dicts( [constants.DEFAULT_LANGUAGE_CODE], feconf.NUMBER_OF_TOP_RATED_EXPLORATIONS_FOR_LIBRARY_PAGE, + display_in_language_code=display_in_language_code, ) ) featured_activity_summary_dicts = ( summary_services.get_featured_activity_summary_dicts( - [constants.DEFAULT_LANGUAGE_CODE] + [constants.DEFAULT_LANGUAGE_CODE], + display_in_language_code=display_in_language_code, ) ) @@ -199,6 +237,7 @@ class LibraryGroupIndexHandlerNormalizedRequestDict(TypedDict): """ group_name: str + display_in_language_code: Optional[str] class LibraryGroupIndexHandler( @@ -220,7 +259,8 @@ class LibraryGroupIndexHandler( feconf.LIBRARY_GROUP_TOP_RATED, ], } - } + }, + 'display_in_language_code': DISPLAY_IN_LANGUAGE_CODE_SCHEMA, } } @@ -230,13 +270,17 @@ def get(self) -> None: # TODO(sll): Support index pages for other language codes. assert self.normalized_request is not None group_name = self.normalized_request['group_name'] + display_in_language_code = self.normalized_request.get( + 'display_in_language_code' + ) activity_list = [] header_i18n_id = '' if group_name == feconf.LIBRARY_GROUP_RECENTLY_PUBLISHED: recently_published_summary_dicts = ( summary_services.get_recently_published_exp_summary_dicts( - feconf.RECENTLY_PUBLISHED_QUERY_LIMIT_FULL_PAGE + feconf.RECENTLY_PUBLISHED_QUERY_LIMIT_FULL_PAGE, + display_in_language_code=display_in_language_code, ) ) if recently_published_summary_dicts: @@ -248,6 +292,7 @@ def get(self) -> None: summary_services.get_top_rated_exploration_summary_dicts( [constants.DEFAULT_LANGUAGE_CODE], feconf.NUMBER_OF_TOP_RATED_EXPLORATIONS_FULL_PAGE, + display_in_language_code=display_in_language_code, ) ) if top_rated_activity_summary_dicts: @@ -278,6 +323,7 @@ class SearchHandlerNormalizedRequestDict(TypedDict): category: str language_code: str offset: Optional[int] + display_in_language_code: Optional[str] class SearchHandler( @@ -317,6 +363,11 @@ class SearchHandler( 'default_value': '', }, 'offset': {'schema': {'type': 'int'}, 'default_value': None}, + # This is the language the results are displayed in, which is the + # learner's site language. It is separate from language_code above, + # which filters which activities are returned by their own + # language. + 'display_in_language_code': DISPLAY_IN_LANGUAGE_CODE_SCHEMA, } } @@ -344,9 +395,16 @@ def get(self) -> None: ) search_offset = self.normalized_request.get('offset') + display_in_language_code = self.normalized_request.get( + 'display_in_language_code' + ) activity_list, new_search_offset = get_matching_activity_dicts( - query_string, categories, language_codes, search_offset + query_string, + categories, + language_codes, + search_offset, + display_in_language_code=display_in_language_code, ) self.values.update( @@ -376,8 +434,9 @@ class ExplorationSummariesHandlerNormalizedRequestDict(TypedDict): normalized_request dictionary. """ - stringified_exp_ids: str + stringified_exp_ids: List[str] include_private_explorations: Optional[bool] + display_in_language_code: Optional[str] class ExplorationSummariesHandler( @@ -400,6 +459,7 @@ class ExplorationSummariesHandler( 'schema': {'type': 'bool'}, 'default_value': False, }, + 'display_in_language_code': DISPLAY_IN_LANGUAGE_CODE_SCHEMA, } } @@ -411,6 +471,9 @@ def get(self) -> None: include_private_exps = self.normalized_request.get( 'include_private_explorations' ) + display_in_language_code = self.normalized_request.get( + 'display_in_language_code' + ) editor_user_id = self.user_id if include_private_exps else None if not editor_user_id: @@ -424,13 +487,16 @@ def get(self) -> None: if include_private_exps: summaries = ( summary_services.get_displayable_exp_summary_dicts_matching_ids( - exp_ids, user=self.user + exp_ids, + user=self.user, + display_in_language_code=display_in_language_code, ) ) else: summaries = ( summary_services.get_displayable_exp_summary_dicts_matching_ids( - exp_ids + exp_ids, + display_in_language_code=display_in_language_code, ) ) self.values.update({'summaries': summaries}) diff --git a/core/controllers/library_test.py b/core/controllers/library_test.py index cddb2f024491e..063018b65463f 100644 --- a/core/controllers/library_test.py +++ b/core/controllers/library_test.py @@ -34,6 +34,8 @@ rights_domain, rights_manager, summary_services, + translation_domain, + translation_services, user_services, ) from core.platform import models @@ -762,6 +764,43 @@ def test_handler_for_recently_published_library_group_page(self) -> None: response_dict['activity_list'][0], ) + def test_library_group_index_handler_with_display_in_language_code( + self, + ) -> None: + """Test library group index handler with display_in_language_code.""" + exp_services.load_demo('0') + exp_summary = exp_fetchers.get_exploration_summary_by_id('0') + + translated_title = translation_domain.TranslatedContent( + 'Hindi Welcome Title', + translation_domain.TranslatableContentFormat.UNICODE_STRING, + needs_update=False, + ) + translation_services.add_new_translation( + feconf.TranslatableEntityType.EXPLORATION, + '0', + exp_summary.version, + 'hi', + feconf.EXPLORATION_TITLE_CONTENT_ID, + translated_title, + ) + + response_dict = self.get_json( + feconf.LIBRARY_GROUP_DATA_URL, + params={ + 'group_name': feconf.LIBRARY_GROUP_RECENTLY_PUBLISHED, + 'display_in_language_code': 'hi', + }, + ) + self.assertEqual(len(response_dict['activity_list']), 1) + self.assertEqual( + response_dict['activity_list'][0]['title'], 'Hindi Welcome Title' + ) + self.assertEqual( + response_dict['activity_list'][0]['translated_metadata_fields'], + ['title'], + ) + def test_handler_for_top_rated_library_group_page(self) -> None: """Test library handler for top rated group page.""" @@ -946,6 +985,41 @@ def test_can_get_public_exploration_summaries(self) -> None: self.logout() + def test_can_get_translated_exploration_summaries(self) -> None: + self.login(self.VIEWER_EMAIL) + + exp_summary = exp_fetchers.get_exploration_summary_by_id( + self.PUBLIC_EXP_ID_EDITOR + ) + translated_title = translation_domain.TranslatedContent( + 'Public Exploration Hindi Title', + translation_domain.TranslatableContentFormat.UNICODE_STRING, + needs_update=False, + ) + translation_services.add_new_translation( + feconf.TranslatableEntityType.EXPLORATION, + self.PUBLIC_EXP_ID_EDITOR, + exp_summary.version, + 'hi', + feconf.EXPLORATION_TITLE_CONTENT_ID, + translated_title, + ) + + response_dict = self.get_json( + feconf.EXPLORATION_SUMMARIES_DATA_URL, + params={ + 'stringified_exp_ids': json.dumps([self.PUBLIC_EXP_ID_EDITOR]), + 'display_in_language_code': 'hi', + }, + ) + summaries = response_dict['summaries'] + self.assertEqual(len(summaries), 1) + self.assertEqual( + summaries[0]['title'], 'Public Exploration Hindi Title' + ) + + self.logout() + def test_can_get_editable_private_exploration_summaries(self) -> None: self.login(self.VIEWER_EMAIL) diff --git a/core/controllers/practice_sessions.py b/core/controllers/practice_sessions.py index 61c42ae0f153b..e4ab5497db4bb 100644 --- a/core/controllers/practice_sessions.py +++ b/core/controllers/practice_sessions.py @@ -27,7 +27,7 @@ topic_fetchers, ) -from typing import Dict, List, Optional, TypedDict +from typing import Dict, List, Optional, Tuple, TypedDict class PracticeSessionsPageDataHandlerNormalizedRequestDict(TypedDict): @@ -153,26 +153,50 @@ def _get_skill_ids_for_node( return node.acquired_skill_ids return [] + def _get_story_and_arc_for_arc_id( + self, topic: topic_domain.Topic, arc_id: str + ) -> Optional[Tuple[story_domain.Story, story_domain.Arc]]: + """Returns the story-arc pair matching the given arc ID. + + The arc_id parameter is a 1-based index that maps to the nth arc in + the first published story of the topic (e.g., '1' maps to the first + arc). + + Args: + topic: Topic. The topic object. + arc_id: str. The arc ID (1-based index). + + Returns: + tuple(Story, Arc) or None. The matching story-arc pair, or None + if no matching arc is found. + """ + arcs_with_stories = story_fetchers.get_all_arcs_with_stories_for_topic( + topic + ) + if arc_id.isascii() and arc_id.isdigit(): + arc_index = int(arc_id) + if 1 <= arc_index <= len(arcs_with_stories): + return arcs_with_stories[arc_index - 1] + return None + def _get_skill_ids_for_arc( self, topic: topic_domain.Topic, arc_id: str ) -> List[str]: """Returns skill IDs associated with all nodes in a given arc. - The arc_id parameter maps to an arc by its ID suffix (e.g., '1' - maps to 'arc_1'). + The arc_id parameter is a 1-based index that maps to the nth arc in + the first published story of the topic (e.g., '1' maps to the first + arc). Args: topic: Topic. The topic object. - arc_id: str. The arc index (e.g., '1'). + arc_id: str. The arc ID (1-based index). Returns: list(str). The skill IDs for all nodes in the arc. """ - arcs_with_stories = story_fetchers.get_all_arcs_with_stories_for_topic( - topic - ) - target_arc_id = 'arc_%s' % arc_id - for story, arc in arcs_with_stories: - if arc.id == target_arc_id: - return story.get_acquired_skill_ids_for_node_ids(arc.node_ids) - return [] + story_arc = self._get_story_and_arc_for_arc_id(topic, arc_id) + if story_arc is None: + return [] + story, arc = story_arc + return story.get_acquired_skill_ids_for_node_ids(arc.node_ids) diff --git a/core/controllers/practice_sessions_test.py b/core/controllers/practice_sessions_test.py index fa3cdfcd55315..36c3f04124c3c 100644 --- a/core/controllers/practice_sessions_test.py +++ b/core/controllers/practice_sessions_test.py @@ -505,6 +505,208 @@ def test_get_succeeds_with_arc_id(self) -> None: 'Skill 2', ) + def test_get_succeeds_with_arc_position(self) -> None: + story_id = 'story_id_2' + exp_id = 'exp_2' + self.save_new_valid_exploration(exp_id, self.admin_id) + self.publish_exploration(self.admin_id, exp_id) + self.save_new_story(story_id, self.admin_id, self.topic_id) + topic_services.add_canonical_story( + self.admin_id, self.topic_id, story_id + ) + story_services.update_story( + self.admin_id, + story_id, + [ + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_ADD_STORY_NODE, + 'node_id': 'node_1', + 'title': 'Chapter 1', + } + ), + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY, + 'property_name': ( + story_domain.STORY_NODE_PROPERTY_EXPLORATION_ID + ), + 'node_id': 'node_1', + 'old_value': None, + 'new_value': exp_id, + } + ), + ], + 'Added node.', + ) + topic_services.publish_story(self.topic_id, story_id, self.admin_id) + story_services.update_story( + self.admin_id, + story_id, + [ + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY, + 'property_name': ( + story_domain.STORY_NODE_PROPERTY_ACQUIRED_SKILL_IDS + ), + 'node_id': 'node_1', + # Here we use cast because the empty list's type cannot + # be inferred, and List[str] is needed to match + # AcceptableChangeDictTypes. + 'old_value': cast(List[str], []), + 'new_value': [self.skill_id1, self.skill_id2], + } + ), + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_CREATE_ARC, + 'arc_id': 'default_arc', + 'title': 'Default Arc', + 'description': 'Default arc', + 'node_ids': ['node_1'], + } + ), + ], + 'Added acquired skill IDs and arc.', + ) + + json_response = self.get_json( + '%s/staging/%s/arc/1' + % (feconf.PRACTICE_SESSION_DATA_URL_PREFIX, 'public-topic-name'), + ) + self.assertEqual(json_response['topic_name'], 'public_topic_name') + self.assertEqual(len(json_response['skill_ids_to_descriptions_map']), 2) + self.assertEqual( + json_response['skill_ids_to_descriptions_map'][self.skill_id1], + 'Skill 1', + ) + self.assertEqual( + json_response['skill_ids_to_descriptions_map'][self.skill_id2], + 'Skill 2', + ) + + def test_get_succeeds_with_second_arc_position(self) -> None: + story_id = 'story_id_2' + exp_id = 'exp_2' + exp_id_2 = 'exp_2_b' + self.save_new_valid_exploration(exp_id, self.admin_id) + self.publish_exploration(self.admin_id, exp_id) + self.save_new_valid_exploration(exp_id_2, self.admin_id) + self.publish_exploration(self.admin_id, exp_id_2) + self.save_new_story(story_id, self.admin_id, self.topic_id) + topic_services.add_canonical_story( + self.admin_id, self.topic_id, story_id + ) + story_services.update_story( + self.admin_id, + story_id, + [ + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_ADD_STORY_NODE, + 'node_id': 'node_1', + 'title': 'Chapter 1', + } + ), + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_ADD_STORY_NODE, + 'node_id': 'node_2', + 'title': 'Chapter 2', + } + ), + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY, + 'property_name': ( + story_domain.STORY_NODE_PROPERTY_EXPLORATION_ID + ), + 'node_id': 'node_1', + 'old_value': None, + 'new_value': exp_id, + } + ), + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY, + 'property_name': ( + story_domain.STORY_NODE_PROPERTY_EXPLORATION_ID + ), + 'node_id': 'node_2', + 'old_value': None, + 'new_value': exp_id_2, + } + ), + ], + 'Added nodes.', + ) + topic_services.publish_story(self.topic_id, story_id, self.admin_id) + story_services.update_story( + self.admin_id, + story_id, + [ + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY, + 'property_name': ( + story_domain.STORY_NODE_PROPERTY_ACQUIRED_SKILL_IDS + ), + 'node_id': 'node_1', + # Here we use cast because the empty list's type cannot + # be inferred, and List[str] is needed to match + # AcceptableChangeDictTypes. + 'old_value': cast(List[str], []), + 'new_value': [self.skill_id1], + } + ), + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY, + 'property_name': ( + story_domain.STORY_NODE_PROPERTY_ACQUIRED_SKILL_IDS + ), + 'node_id': 'node_2', + # Here we use cast because the empty list's type cannot + # be inferred, and List[str] is needed to match + # AcceptableChangeDictTypes. + 'old_value': cast(List[str], []), + 'new_value': [self.skill_id2], + } + ), + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_CREATE_ARC, + 'arc_id': 'default_arc', + 'title': 'Default Arc', + 'description': 'Default arc', + 'node_ids': ['node_1'], + } + ), + story_domain.StoryChange( + { + 'cmd': story_domain.CMD_CREATE_ARC, + 'arc_id': 'arc_2', + 'title': 'Arc 2', + 'description': 'Second arc', + 'node_ids': ['node_2'], + } + ), + ], + 'Added acquired skill IDs and arcs.', + ) + + json_response = self.get_json( + '%s/staging/%s/arc/2' + % (feconf.PRACTICE_SESSION_DATA_URL_PREFIX, 'public-topic-name'), + ) + self.assertEqual(json_response['topic_name'], 'public_topic_name') + self.assertEqual(len(json_response['skill_ids_to_descriptions_map']), 1) + self.assertEqual( + json_response['skill_ids_to_descriptions_map'][self.skill_id2], + 'Skill 2', + ) + def test_get_returns_empty_for_zero_node_id(self) -> None: json_response = self.get_json( '%s/staging/%s/0' @@ -545,6 +747,30 @@ def test_get_returns_empty_for_large_arc_index(self) -> None: self.assertEqual(json_response['topic_name'], 'public_topic_name') self.assertEqual(len(json_response['skill_ids_to_descriptions_map']), 0) + def test_get_returns_empty_for_non_ascii_arc_id(self) -> None: + json_response = self.get_json( + '%s/staging/%s/arc/%s' + % ( + feconf.PRACTICE_SESSION_DATA_URL_PREFIX, + 'public-topic-name', + '%D9%A1', + ), + ) + self.assertEqual(json_response['topic_name'], 'public_topic_name') + self.assertEqual(len(json_response['skill_ids_to_descriptions_map']), 0) + + def test_get_returns_empty_for_emoji_arc_id(self) -> None: + json_response = self.get_json( + '%s/staging/%s/arc/%s' + % ( + feconf.PRACTICE_SESSION_DATA_URL_PREFIX, + 'public-topic-name', + '%F0%9F%98%80', + ), + ) + self.assertEqual(json_response['topic_name'], 'public_topic_name') + self.assertEqual(len(json_response['skill_ids_to_descriptions_map']), 0) + def test_get_node_skills_with_deleted_story_in_topic(self) -> None: story_id = 'story_id' deleted_story_id = 'del_story' diff --git a/core/controllers/tasks.py b/core/controllers/tasks.py index 2fd732943569a..cc859d435c446 100644 --- a/core/controllers/tasks.py +++ b/core/controllers/tasks.py @@ -488,6 +488,15 @@ def post(self) -> None: deferred_task_function(*payload['args'], **payload['kwargs']) + # If the deferred task is for voiceover regeneration, do not update + # its status immediately. Instead, wait until all child processes + # have completed. The final status will be set at the end of the + # parent task. + if ( + cloud_task_run_domain_instance.is_voiceover_regeneration_parent_task() + ): + return + updated_cloud_task_run_domain_instance = ( taskqueue_services.get_cloud_task_run_by_model_id( cloud_task_model_id diff --git a/core/controllers/tasks_test.py b/core/controllers/tasks_test.py index 06acf2cd903ee..bbc12f9369915 100644 --- a/core/controllers/tasks_test.py +++ b/core/controllers/tasks_test.py @@ -748,7 +748,11 @@ def test_should_handle_deferred_tasks_successfully(self) -> None: def test_should_handle_voiceover_deferred_tasks_successfully(self) -> None: exploration_id = 'exploration_id' - self.save_new_valid_exploration(exploration_id, self.owner_id) + self.save_new_valid_exploration( + exploration_id, + self.owner_id, + content_html='Hello world', + ) rights_manager.publish_exploration(self.owner, exploration_id) url = feconf.TASK_URL_DEFERRED @@ -795,7 +799,7 @@ def test_should_handle_voiceover_deferred_tasks_successfully(self) -> None: taskqueue_services.get_cloud_task_run_by_model_id(new_model_id) ) assert cloud_task_run_model_obj is not None - self.assertEqual(cloud_task_run_model_obj.latest_job_state, 'SUCCEEDED') + self.assertEqual(cloud_task_run_model_obj.latest_job_state, 'RUNNING') def test_should_handle_failure_case_for_voiceover_deferred_tasks( self, diff --git a/core/controllers/topic_editor.py b/core/controllers/topic_editor.py index f1141ba13a561..bd9dacaf8ca8e 100644 --- a/core/controllers/topic_editor.py +++ b/core/controllers/topic_editor.py @@ -221,6 +221,7 @@ def get(self, topic_id: str) -> None: upcoming_chapters_expected_days ), 'overdue_chapters_count': overdue_chapters_count, + 'arcs': [arc.to_dict() for arc in story.story_contents.arcs], } updated_canonical_story_summary_dicts.append( updated_canonical_story_summary_dict diff --git a/core/controllers/topic_editor_test.py b/core/controllers/topic_editor_test.py index 6b5c224cf2aaa..e5a6cb2c3219a 100644 --- a/core/controllers/topic_editor_test.py +++ b/core/controllers/topic_editor_test.py @@ -261,6 +261,14 @@ def test_handler_updates_story_summary_dicts(self) -> None: ] story.story_contents.initial_node_id = 'node_1' story.story_contents.next_node_id = 'node_4' + story.story_contents.add_arc( + story_domain.Arc( + 'arc_1', + 'Adventure 1', + 'First adventure', + ['node_1', 'node_2', 'node_3'], + ) + ) self.assertEqual(response['canonical_story_summary_dicts'], []) self.assertEqual(response['additional_story_summary_dicts'], []) @@ -365,6 +373,17 @@ def mock_get_current_time_in_millisecs() -> int: self.assertEqual( canonical_story_summary_dict['overdue_chapters_count'], 1 ) + self.assertEqual( + canonical_story_summary_dict['arcs'], + [ + { + 'id': 'arc_1', + 'title': 'Adventure 1', + 'description': 'First adventure', + 'node_ids': ['node_1', 'node_2', 'node_3'], + } + ], + ) self.assertEqual( additional_story_summary_dict['description'], diff --git a/core/domain/certificate_assessment_domain.py b/core/domain/certificate_assessment_domain.py index a27ef5ee15ab7..17763bd876b47 100644 --- a/core/domain/certificate_assessment_domain.py +++ b/core/domain/certificate_assessment_domain.py @@ -32,6 +32,9 @@ MAX_TIME_LIMIT_IN_MINUTES = 60 MIN_TOTAL_QUESTIONS = 3 MAX_TOTAL_QUESTIONS = 50 +# Minimum time (in minutes) that must elapse between a learner's last +# attempt start and their next attempt start for the same certificate. +MIN_TIME_BETWEEN_ATTEMPTS_IN_MINUTES = 10 # Maximum size in bytes of a serialized selected_answer for a certificate # assessment. Each response is stored in its own Datastore entity, so this diff --git a/core/domain/certificate_assessment_services.py b/core/domain/certificate_assessment_services.py index 5ee9bc0f7d3f5..46cc88f7f4cc0 100644 --- a/core/domain/certificate_assessment_services.py +++ b/core/domain/certificate_assessment_services.py @@ -21,6 +21,7 @@ import collections import datetime import json +import math import secrets import sys @@ -84,6 +85,23 @@ class CertificateAssessmentAttemptNotReadyException(Exception): pass +class CertificateAssessmentAttemptCooldownException(Exception): + """Raised when a learner starts a new attempt during the cooldown window. + + The message here is server-side only and intentionally not user-facing + English: the HTTP handler converts this into a structured response + (I18N key + remaining_minutes) so the frontend can render a translated + message via its translate pipes. + """ + + def __init__(self, remaining_minutes: int) -> None: + super().__init__( + 'Assessment attempt blocked by cooldown; %d minute(s) remaining.' + % remaining_minutes + ) + self.remaining_minutes = remaining_minutes + + class CertificateOfferingClassroomSummary(TypedDict): """Dict representation of a classroom-facing certificate offering.""" @@ -441,27 +459,29 @@ def _build_version_data( } -def _get_in_progress_attempt_for_learner( - learner_id: str, +def _get_most_recent_attempt_for_learner_and_certificate( + learner_id: str, certificate_id: str ) -> Optional[gae_models.CertificateAssessmentAttemptModel]: - """Returns the learner's in-progress assessment attempt, if any.""" - return gae_models.CertificateAssessmentAttemptModel.query( - gae_models.CertificateAssessmentAttemptModel.learner_id == learner_id, - gae_models.CertificateAssessmentAttemptModel.is_submitted # pylint: disable=singleton-comparison - == False, - ).get() - - -def _get_active_attempt_for_learner( - learner_id: str, -) -> gae_models.CertificateAssessmentAttemptModel: - """Returns the learner's active assessment attempt or raises.""" - attempt_model = _get_in_progress_attempt_for_learner(learner_id) - if attempt_model is None: - raise utils.ValidationError( - 'No active certificate assessment attempt was found.' + """Returns the learner's most recent attempt for a certificate, if any. + + Attempts are ordered by their creation time. We would prefer to sort by + started_at, but that property is not indexed (see + CertificateAssessmentAttemptModel.started_at) and Datastore cannot order + by a computed expression such as IF(started_at, -started_at, + -created_on), so started_at cannot be used as a query sort key. Since + attempts are created when they start, creation order matches start order, + so ordering by created_on yields the most recently started attempt. + """ + return ( + gae_models.CertificateAssessmentAttemptModel.query( + gae_models.CertificateAssessmentAttemptModel.learner_id + == learner_id, + gae_models.CertificateAssessmentAttemptModel.certificate_id + == certificate_id, ) - return attempt_model + .order(-gae_models.CertificateAssessmentAttemptModel.created_on) + .get() + ) def _get_certificate_assessment_attempt_model( @@ -550,18 +570,39 @@ def start_certificate_assessment_attempt( Raises: CertificateAssessmentAttemptNotReadyException. If the assessment can no longer be started because the question pool is invalid. - utils.ValidationError. If the learner already has an in-progress - attempt. + CertificateAssessmentAttemptCooldownException. If the learner started an attempt for this + certificate less than MIN_TIME_BETWEEN_ATTEMPTS_IN_MINUTES minutes + ago. """ def _start_txn() -> Tuple[ certificate_assessment_domain.CertificateAssessmentAttempt, List[Dict[str, Union[str, int]]], ]: - if _get_in_progress_attempt_for_learner(learner_id) is not None: - raise utils.ValidationError( - 'You already have an in-progress certificate assessment attempt.' + most_recent_attempt = ( + _get_most_recent_attempt_for_learner_and_certificate( + learner_id, certificate_id + ) + ) + if most_recent_attempt is not None: + cooldown = datetime.timedelta( + minutes=( + certificate_assessment_domain.MIN_TIME_BETWEEN_ATTEMPTS_IN_MINUTES + ) ) + remaining_cooldown = cooldown - ( + datetime.datetime.utcnow() - most_recent_attempt.started_at + ) + if remaining_cooldown > datetime.timedelta(seconds=0): + # Round up so the reported wait never lapses before the actual + # cooldown expires, and never drop below one minute. + remaining_minutes = max( + 1, + int(math.ceil(remaining_cooldown.total_seconds() / 60)), + ) + raise CertificateAssessmentAttemptCooldownException( + remaining_minutes + ) attempt_model = gae_models.CertificateAssessmentAttemptModel.create( learner_id=learner_id, certificate_id=certificate_id, diff --git a/core/domain/certificate_assessment_services_test.py b/core/domain/certificate_assessment_services_test.py index 0a501043bebad..908c0bb7e1c9d 100644 --- a/core/domain/certificate_assessment_services_test.py +++ b/core/domain/certificate_assessment_services_test.py @@ -39,7 +39,7 @@ from core.storage.certificate_assessment import gae_models from core.tests import test_utils -from typing import Sequence, TypedDict +from typing import Sequence, TypedDict, cast MYPY = False if MYPY: # pragma: no cover @@ -528,17 +528,17 @@ def test_build_version_data_includes_certificate_and_topic_versions( }, ) - def test_get_active_attempt_for_learner_returns_or_raises(self) -> None: - with self.assertRaisesRegex( - utils.ValidationError, - 'No active certificate assessment attempt was found.', - ): + def test_get_most_recent_attempt_returns_latest_for_learner_and_certificate( + self, + ) -> None: + self.assertIsNone( getattr( certificate_assessment_services, - '_get_active_attempt_for_learner', - )('learner_1') + '_get_most_recent_attempt_for_learner_and_certificate', + )('learner_1', 'cert_1') + ) - attempt = gae_models.CertificateAssessmentAttemptModel.create( + gae_models.CertificateAssessmentAttemptModel.create( learner_id='learner_1', certificate_id='cert_1', total_score=0.0, @@ -555,12 +555,49 @@ def test_get_active_attempt_for_learner_returns_or_raises(self) -> None: finished_at=None, is_submitted=False, ) + most_recent_attempt = ( + gae_models.CertificateAssessmentAttemptModel.create( + learner_id='learner_1', + certificate_id='cert_1', + total_score=0.0, + attempt_index=2, + attempt_data={}, + version_data={ + 'certificate_id': 'cert_1', + 'certificate_version': 1, + 'topic_versions': {'topic_1': 1}, + 'question_versions': {'question_1': 1}, + 'question_topic_links': {'question_1': ['topic_1']}, + }, + started_at=datetime.datetime.utcnow(), + finished_at=None, + is_submitted=False, + ) + ) + # An attempt for a different certificate is not considered. + gae_models.CertificateAssessmentAttemptModel.create( + learner_id='learner_1', + certificate_id='cert_2', + total_score=0.0, + attempt_index=1, + attempt_data={}, + version_data={ + 'certificate_id': 'cert_2', + 'certificate_version': 1, + 'topic_versions': {'topic_1': 1}, + 'question_versions': {'question_1': 1}, + 'question_topic_links': {'question_1': ['topic_1']}, + }, + started_at=datetime.datetime.utcnow(), + finished_at=None, + is_submitted=False, + ) self.assertEqual( getattr( certificate_assessment_services, - '_get_active_attempt_for_learner', - )('learner_1').id, - attempt.id, + '_get_most_recent_attempt_for_learner_and_certificate', + )('learner_1', 'cert_1').id, + most_recent_attempt.id, ) def test_get_next_attempt_index_uses_highest_submitted_index( @@ -755,7 +792,7 @@ def test_get_certificate_assessment_attempt_raises_for_missing_attempt( 'missing_attempt_id' ) - def test_start_certificate_assessment_attempt_rejects_in_progress_attempt( + def test_start_certificate_assessment_attempt_rejects_attempt_within_cooldown( self, ) -> None: owner_id = self.get_user_id_from_email(self.OWNER_EMAIL) @@ -785,24 +822,25 @@ def test_start_certificate_assessment_attempt_rejects_in_progress_attempt( async_status='Available', ) - in_progress_attempt = ( - gae_models.CertificateAssessmentAttemptModel.create( - learner_id=owner_id, - certificate_id=created_offering.certificate_id, - total_score=0.0, - attempt_index=1, - attempt_data={}, - version_data={ - 'certificate_id': created_offering.certificate_id, - 'certificate_version': 1, - 'topic_versions': {topic_id: 1}, - 'question_versions': {'dummy_question_id': 1}, - 'question_topic_links': {'dummy_question_id': [topic_id]}, - }, - started_at=datetime.datetime.utcnow(), - finished_at=None, - is_submitted=False, - ) + recent_attempt = gae_models.CertificateAssessmentAttemptModel.create( + learner_id=owner_id, + certificate_id=created_offering.certificate_id, + total_score=0.0, + attempt_index=1, + attempt_data={}, + version_data={ + 'certificate_id': created_offering.certificate_id, + 'certificate_version': 1, + 'topic_versions': {topic_id: 1}, + 'question_versions': {'dummy_question_id': 1}, + 'question_topic_links': {'dummy_question_id': [topic_id]}, + }, + started_at=( + datetime.datetime.utcnow() + - datetime.timedelta(minutes=4, seconds=30) + ), + finished_at=None, + is_submitted=False, ) with mock.patch.object( @@ -810,19 +848,170 @@ def test_start_certificate_assessment_attempt_rejects_in_progress_attempt( 'validate_certificate_assessment_offering', return_value={'is_valid': True}, ), self.assertRaisesRegex( - utils.ValidationError, - 'You already have an in-progress certificate assessment attempt.', - ): + certificate_assessment_services.CertificateAssessmentAttemptCooldownException, + # The remaining wait is about 5 minutes and 30 seconds, so it + # must be reported rounded up to 6 minutes. + r'Assessment attempt blocked by cooldown; 6 minute\(s\) remaining\.', + ) as context: certificate_assessment_services.start_certificate_assessment_attempt( created_offering.certificate_id, owner_id, ) + self.assertEqual( + # Here we use cast because context.exception is typed as BaseException + # and needs to be narrowed to access the remaining_minutes attribute. + cast( + certificate_assessment_services.CertificateAssessmentAttemptCooldownException, + context.exception, + ).remaining_minutes, + 6, + ) self.assertIsNotNone( gae_models.CertificateAssessmentAttemptModel.get_by_id( - in_progress_attempt.id + recent_attempt.id + ) + ) + + def test_start_certificate_assessment_attempt_rejects_attempt_with_seconds_of_cooldown_left( + self, + ) -> None: + owner_id = self.get_user_id_from_email(self.OWNER_EMAIL) + question_id_1 = question_services.get_new_question_id() + question_id_2 = question_services.get_new_question_id() + question_id_3 = question_services.get_new_question_id() + self._create_assessment_question( + question_id_1, 'skill_1', 'Answer', 0.6 + ) + self._create_assessment_question( + question_id_2, 'skill_2', 'Answer 2', 0.3 + ) + self._create_assessment_question( + question_id_3, 'skill_3', 'Answer 3', 0.9 + ) + topic_id = self._create_assessment_topic_with_skills( + ['skill_1', 'skill_2', 'skill_3'] + ) + created_offering = certificate_assessment_services.create_certificate_assessment_offering( + title='Arithmetic Check', + description='Checks arithmetic basics.', + classroom_id=self.classroom_id, + topic_ids=[topic_id], + total_questions=3, + time_limit_in_minutes=30, + demonstrates=['Arithmetic reasoning'], + async_status='Available', + ) + + gae_models.CertificateAssessmentAttemptModel.create( + learner_id=owner_id, + certificate_id=created_offering.certificate_id, + total_score=0.0, + attempt_index=1, + attempt_data={}, + version_data={ + 'certificate_id': created_offering.certificate_id, + 'certificate_version': 1, + 'topic_versions': {topic_id: 1}, + 'question_versions': {'dummy_question_id': 1}, + 'question_topic_links': {'dummy_question_id': [topic_id]}, + }, + started_at=( + datetime.datetime.utcnow() + - datetime.timedelta(minutes=9, seconds=30) + ), + finished_at=None, + is_submitted=False, + ) + + with mock.patch.object( + certificate_assessment_services, + 'validate_certificate_assessment_offering', + return_value={'is_valid': True}, + ), self.assertRaisesRegex( + certificate_assessment_services.CertificateAssessmentAttemptCooldownException, + r'Assessment attempt blocked by cooldown; 1 minute\(s\) remaining\.', + ) as context: + certificate_assessment_services.start_certificate_assessment_attempt( + created_offering.certificate_id, + owner_id, ) + self.assertEqual( + # Here we use cast because context.exception is typed as BaseException + # and needs to be narrowed to access the remaining_minutes attribute. + cast( + certificate_assessment_services.CertificateAssessmentAttemptCooldownException, + context.exception, + ).remaining_minutes, + 1, + ) + + def test_start_certificate_assessment_attempt_allows_attempt_after_cooldown( + self, + ) -> None: + owner_id = self.get_user_id_from_email(self.OWNER_EMAIL) + question_id_1 = question_services.get_new_question_id() + question_id_2 = question_services.get_new_question_id() + question_id_3 = question_services.get_new_question_id() + self._create_assessment_question( + question_id_1, 'skill_1', 'Answer', 0.6 + ) + self._create_assessment_question( + question_id_2, 'skill_2', 'Answer 2', 0.3 ) + self._create_assessment_question( + question_id_3, 'skill_3', 'Answer 3', 0.9 + ) + topic_id = self._create_assessment_topic_with_skills( + ['skill_1', 'skill_2', 'skill_3'] + ) + created_offering = certificate_assessment_services.create_certificate_assessment_offering( + title='Arithmetic Check', + description='Checks arithmetic basics.', + classroom_id=self.classroom_id, + topic_ids=[topic_id], + total_questions=3, + time_limit_in_minutes=30, + demonstrates=['Arithmetic reasoning'], + async_status='Available', + ) + + gae_models.CertificateAssessmentAttemptModel.create( + learner_id=owner_id, + certificate_id=created_offering.certificate_id, + total_score=0.0, + attempt_index=1, + attempt_data={}, + version_data={ + 'certificate_id': created_offering.certificate_id, + 'certificate_version': 1, + 'topic_versions': {topic_id: 1}, + 'question_versions': {'dummy_question_id': 1}, + 'question_topic_links': {'dummy_question_id': [topic_id]}, + }, + started_at=( + datetime.datetime.utcnow() + - datetime.timedelta(minutes=10, seconds=1) + ), + finished_at=None, + is_submitted=False, + ) + + with mock.patch.object( + certificate_assessment_services, + 'validate_certificate_assessment_offering', + return_value={'is_valid': True}, + ): + attempt, _ = ( + certificate_assessment_services.start_certificate_assessment_attempt( + created_offering.certificate_id, + owner_id, + ) + ) + + self.assertEqual(attempt.attempt_index, 0) + self.assertFalse(attempt.is_submitted) + self.assertIsNotNone(attempt.started_at) def test_start_certificate_assessment_attempt_blocks_invalid_offering( self, diff --git a/core/domain/cloud_task_domain.py b/core/domain/cloud_task_domain.py index db037ce12fb7a..23bffd3b7c4b7 100644 --- a/core/domain/cloud_task_domain.py +++ b/core/domain/cloud_task_domain.py @@ -38,6 +38,7 @@ class CloudTaskRunDict(TypedDict): last_updated: str current_retry_attempt: int created_on: str + additional_contextual_information: Dict[str, str] class CloudTaskRun: @@ -55,6 +56,7 @@ def __init__( current_retry_attempt: int, last_updated: datetime.datetime, created_on: datetime.datetime, + additional_contextual_information: Dict[str, str], ) -> None: self.task_run_id = task_run_id self.cloud_task_name = cloud_task_name @@ -68,6 +70,9 @@ def __init__( self.current_retry_attempt = current_retry_attempt self.last_updated = last_updated self.created_on = created_on + self.additional_contextual_information = ( + additional_contextual_information + ) def to_dict(self) -> CloudTaskRunDict: """Returns a dictionary representation of this domain object. @@ -89,6 +94,7 @@ def to_dict(self) -> CloudTaskRunDict: 'current_retry_attempt': self.current_retry_attempt, 'last_updated': self.last_updated.isoformat(), 'created_on': self.created_on.isoformat(), + 'additional_contextual_information': self.additional_contextual_information, } def to_dict_with_timezone_info(self) -> CloudTaskRunDict: @@ -117,6 +123,7 @@ def to_dict_with_timezone_info(self) -> CloudTaskRunDict: 'created_on': self.created_on.replace( tzinfo=datetime.timezone.utc ).isoformat(), + 'additional_contextual_information': self.additional_contextual_information, } @classmethod @@ -148,8 +155,34 @@ def from_dict(cls, cloud_task_run_dict: CloudTaskRunDict) -> CloudTaskRun: created_on=datetime.datetime.fromisoformat( cloud_task_run_dict['created_on'] ), + additional_contextual_information=cloud_task_run_dict.get( + 'additional_contextual_information', {} + ), ) + def is_voiceover_regeneration_parent_task(self) -> bool: + """Checks whether the cloud task run is related to a parent task for + voiceover regeneration. + + Returns: + bool. True if the cloud task run is related to a parent task for + voiceover regeneration, False otherwise. + """ + return self.function_id in { + feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ + 'FUNCTION_ID_REGENERATE_VOICEOVERS_ON_EXP_UPDATE' + ], + feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ + 'FUNCTION_ID_REGENERATE_VOICEOVERS_ON_EXP_CURATION' + ], + feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ + 'FUNCTION_ID_REGENERATE_VOICEOVERS_AFTER_ACCEPTING_SUGGESTION' + ], + feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ + 'FUNCTION_ID_REGENERATE_VOICEOVERS_BY_LANGUAGE_ACCENT' + ], + } + class VoiceoverRegenerationJobDict(TypedDict): """Dictionary representing the VoiceoverRegenerationJob object.""" diff --git a/core/domain/cloud_task_domain_test.py b/core/domain/cloud_task_domain_test.py index 5ebcac1b6cb05..43a5ac4cce55f 100644 --- a/core/domain/cloud_task_domain_test.py +++ b/core/domain/cloud_task_domain_test.py @@ -59,6 +59,7 @@ def test_should_create_domain_object_correctly(self) -> None: 0, last_updated, created_on, + {}, ) self.assertEqual(cloud_task_run.task_run_id, cloud_task_run_id) @@ -100,6 +101,7 @@ def test_should_create_domain_object_from_dict(self) -> None: 'current_retry_attempt': 0, 'last_updated': last_updated.isoformat(), 'created_on': created_on.isoformat(), + 'additional_contextual_information': {}, } cloud_task_run = cloud_task_domain.CloudTaskRun.from_dict( @@ -121,6 +123,7 @@ def test_should_convert_datetime_fields_with_timezone_info(self) -> None: current_retry_attempt=0, last_updated=datetime.datetime(2026, 1, 2, 3, 4, 5), created_on=datetime.datetime(2026, 1, 2, 3, 4, 6), + additional_contextual_information={}, ) cloud_task_run_dict = cloud_task_run.to_dict_with_timezone_info() diff --git a/core/domain/exp_services_test.py b/core/domain/exp_services_test.py index 6c178094b90fd..2a70278564820 100644 --- a/core/domain/exp_services_test.py +++ b/core/domain/exp_services_test.py @@ -7243,6 +7243,109 @@ def mock_add_documents_to_index( self.assertEqual(add_docs_counter.times_called, 1) + def test_translated_metadata_is_added_correctly_to_index(self) -> None: + exp_id = 'id0' + self.save_new_valid_exploration( + exp_id, + self.owner_id, + title='Fractions', + category='Mathematics', + objective='Learn about fractions', + ) + exp_services.update_exploration( + self.owner_id, + exp_id, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY, + 'property_name': 'tags', + 'new_value': ['numbers'], + } + ) + ], + 'Add a tag.', + ) + rights_manager.publish_exploration(self.owner, exp_id) + exp = exp_fetchers.get_exploration_by_id(exp_id) + + # Hindi translates the title, the objective and the tag. Bengali + # translates only the title, and its objective translation is stale. + translation_models.EntityTranslationsModel.create_new( + feconf.TranslatableEntityType.EXPLORATION.value, + exp_id, + exp.version, + 'hi', + { + feconf.EXPLORATION_TITLE_CONTENT_ID: { + 'content_value': 'भिन्न', + 'needs_update': False, + 'content_format': 'unicode', + }, + feconf.EXPLORATION_OBJECTIVE_CONTENT_ID: { + 'content_value': 'भिन्न के बारे में जानें', + 'needs_update': False, + 'content_format': 'unicode', + }, + f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_0': { + 'content_value': 'संख्याएँ', + 'needs_update': False, + 'content_format': 'unicode', + }, + }, + ).put() + translation_models.EntityTranslationsModel.create_new( + feconf.TranslatableEntityType.EXPLORATION.value, + exp_id, + exp.version, + 'bn', + { + feconf.EXPLORATION_TITLE_CONTENT_ID: { + 'content_value': 'ভগ্নাংশ', + 'needs_update': False, + 'content_format': 'unicode', + }, + feconf.EXPLORATION_OBJECTIVE_CONTENT_ID: { + 'content_value': 'পুরানো উদ্দেশ্য', + 'needs_update': True, + 'content_format': 'unicode', + }, + }, + ).put() + + actual_docs = [] + + def mock_add_documents_to_index( + docs: List[Dict[str, str]], index: str + ) -> None: + self.assertEqual(index, exp_services.SEARCH_INDEX_EXPLORATIONS) + actual_docs.extend(docs) + + add_docs_swap = self.swap( + search_services, + 'add_documents_to_index', + mock_add_documents_to_index, + ) + + with add_docs_swap: + exp_services.index_explorations_given_ids([exp_id]) + + self.assertEqual(len(actual_docs), 1) + indexed_doc = actual_docs[0] + # The English metadata is indexed unchanged. + self.assertEqual(indexed_doc['title'], 'Fractions') + self.assertEqual(indexed_doc['objective'], 'Learn about fractions') + self.assertEqual(indexed_doc['tags'], ['numbers']) + # Both title translations are indexed, sorted. Sorting is by Unicode + # code point, so the Devanagari title precedes the Bengali one. + self.assertEqual(indexed_doc['translated_titles'], ['भिन्न', 'ভগ্নাংশ']) + # The Bengali objective is stale, so only the Hindi one is indexed. + self.assertEqual( + indexed_doc['translated_objectives'], ['भिन्न के बारे में जानें'] + ) + # Only Hindi translated the tag. + self.assertEqual(indexed_doc['translated_tags'], ['संख्याएँ']) + def test_updated_exploration_is_added_correctly_to_index(self) -> None: exp_id = 'id0' exp_title = 'title 0' @@ -7251,20 +7354,26 @@ def test_updated_exploration_is_added_correctly_to_index(self) -> None: initial_exp_doc = { 'category': 'cat0', 'id': 'id0', - 'language_code': 'en', + 'language_code': ['en'], 'objective': 'An objective', 'rank': 20, 'tags': [], 'title': 'title 0', + 'translated_titles': [], + 'translated_objectives': [], + 'translated_tags': [], } updated_exp_doc = { 'category': 'cat1', 'id': 'id0', - 'language_code': 'en', + 'language_code': ['en'], 'objective': 'An objective', 'rank': 20, 'tags': [], 'title': 'title 0', + 'translated_titles': [], + 'translated_objectives': [], + 'translated_tags': [], } def mock_add_documents_to_index( diff --git a/core/domain/search_services.py b/core/domain/search_services.py index da6136047fb5a..e67557b84bb49 100644 --- a/core/domain/search_services.py +++ b/core/domain/search_services.py @@ -20,17 +20,20 @@ import math -from core import utils +from core import feconf, utils from core.domain import ( blog_domain, collection_domain, exp_domain, + exp_fetchers, rights_domain, rights_manager, + translation_domain, + translation_fetchers, ) from core.platform import models -from typing import Final, List, Optional, Tuple, TypedDict +from typing import Final, List, Optional, Tuple, TypedDict, Union MYPY = False if MYPY: # pragma: no cover @@ -68,7 +71,7 @@ class DomainSearchDict(TypedDict): """Dictionary representing the search dictionary of a domain object.""" id: str - language_code: str + language_code: Union[str, List[str]] title: str category: str tags: List[str] @@ -76,6 +79,108 @@ class DomainSearchDict(TypedDict): rank: int +class ExplorationSearchDict(DomainSearchDict): + """Dictionary representing the search dictionary of an exploration.""" + + translated_titles: List[str] + translated_objectives: List[str] + translated_tags: List[str] + + +# This duplicates the check that #27083 adds as the public +# translation_services.get_up_to_date_translation. This copy is replaced by a +# call to that helper once #27083 merges. +def _get_up_to_date_translation( + entity_translation: translation_domain.EntityTranslation, + content_id: str, +) -> Optional[str]: + """Returns the translation of the given content ID, if it is usable. + + Args: + entity_translation: EntityTranslation. The entity's translations. + content_id: str. The content ID to look up. + + Returns: + str or None. The translated value, or None when there is no translation + or the translation is stale. + """ + translated_content = entity_translation.translations.get(content_id) + # A translation that needs an update is stale because the English content + # changed after it was accepted, so it is not shown. + if ( + translated_content is not None + and not translated_content.needs_update + and isinstance(translated_content.content_value, str) + ): + return translated_content.content_value + return None + + +def _get_translated_search_texts( + exp_id: str, exp_version: int, num_tags: int +) -> Tuple[List[str], List[str], List[str], List[str]]: + """Returns the translated titles, objectives, and tags for an exploration. + + Args: + exp_id: str. The exploration ID. + exp_version: int. The exploration version. + num_tags: int. The number of tags in the exploration. + + Returns: + tuple(list(str), list(str), list(str), list(str)). The sorted, de-duplicated + translated titles, translated objectives, translated tags, and translated language codes. + """ + entity_translations = ( + translation_fetchers.get_all_entity_translations_for_entity( + feconf.TranslatableEntityType.EXPLORATION, exp_id, exp_version + ) + ) + + translated_titles = set() + translated_objectives = set() + translated_tags = set() + translated_languages = set() + + for entity_translation in entity_translations: + has_valid_translation = False + + # Title translation. + title_translation = _get_up_to_date_translation( + entity_translation, feconf.EXPLORATION_TITLE_CONTENT_ID + ) + if title_translation is not None: + translated_titles.add(title_translation) + has_valid_translation = True + + # Objective translation. + objective_translation = _get_up_to_date_translation( + entity_translation, feconf.EXPLORATION_OBJECTIVE_CONTENT_ID + ) + if objective_translation is not None: + translated_objectives.add(objective_translation) + has_valid_translation = True + + # Tags translations. + for idx in range(num_tags): + tag_content_id = f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_{idx}' + tag_translation = _get_up_to_date_translation( + entity_translation, tag_content_id + ) + if tag_translation is not None: + translated_tags.add(tag_translation) + has_valid_translation = True + + if has_valid_translation: + translated_languages.add(entity_translation.language_code) + + return ( + sorted(list(translated_titles)), + sorted(list(translated_objectives)), + sorted(list(translated_tags)), + sorted(list(translated_languages)), + ) + + def index_exploration_summaries( exp_summaries: List[exp_domain.ExplorationSummary], ) -> None: @@ -85,37 +190,86 @@ def index_exploration_summaries( exp_summaries: list(ExplorationSummary). List of Exp Summary domain objects to be indexed. """ + indexable = [s for s in exp_summaries if _should_index_exploration(s)] + explorations_dict = exp_fetchers.get_multiple_explorations_by_id( + [s.id for s in indexable], strict=False + ) + + documents: List[ExplorationSearchDict] = [] + for exp_summary in indexable: + exploration = explorations_dict.get(exp_summary.id) + if exploration is not None: + ( + translated_titles, + translated_objectives, + translated_tags, + translated_languages, + ) = _get_translated_search_texts( + exp_summary.id, exploration.version, len(exploration.tags) + ) + else: + ( + translated_titles, + translated_objectives, + translated_tags, + translated_languages, + ) = ( + [], + [], + [], + [], + ) + + documents.append( + _exp_summary_to_search_dict( + exp_summary, + translated_titles, + translated_objectives, + translated_tags, + translated_languages, + ) + ) + platform_search_services.add_documents_to_index( - [ - _exp_summary_to_search_dict(exp_summary) - for exp_summary in exp_summaries - if _should_index_exploration(exp_summary) - ], + documents, SEARCH_INDEX_EXPLORATIONS, ) def _exp_summary_to_search_dict( exp_summary: exp_domain.ExplorationSummary, -) -> DomainSearchDict: + translated_titles: List[str], + translated_objectives: List[str], + translated_tags: List[str], + translated_languages: List[str], +) -> ExplorationSearchDict: """Updates the dict to be returned, whether the given exploration is to be indexed for further queries or not. Args: exp_summary: ExplorationSummary. ExplorationSummary domain object. + translated_titles: list(str). List of translated titles. + translated_objectives: list(str). List of translated objectives. + translated_tags: list(str). List of translated tags. + translated_languages: list(str). List of translated languages. Returns: dict. The representation of the given exploration, in a form that can be used by the search index. """ - doc: DomainSearchDict = { + doc: ExplorationSearchDict = { 'id': exp_summary.id, - 'language_code': exp_summary.language_code, + 'language_code': sorted( + list(set([exp_summary.language_code] + translated_languages)) + ), 'title': exp_summary.title, 'category': exp_summary.category, 'tags': exp_summary.tags, 'objective': exp_summary.objective, 'rank': get_search_rank_from_exp_summary(exp_summary), + 'translated_titles': translated_titles, + 'translated_objectives': translated_objectives, + 'translated_tags': translated_tags, } return doc diff --git a/core/domain/search_services_test.py b/core/domain/search_services_test.py index 2720589bcf229..9c79574f95517 100644 --- a/core/domain/search_services_test.py +++ b/core/domain/search_services_test.py @@ -18,9 +18,11 @@ from __future__ import annotations +from core import feconf from core.domain import ( blog_services, collection_services, + exp_domain, exp_fetchers, exp_services, rating_services, @@ -35,6 +37,10 @@ gae_search_services = models.Registry.import_search_services() +(translation_models,) = models.Registry.import_models( + [models.Names.TRANSLATION] +) + class SearchServicesUnitTests(test_utils.GenericTestBase): """Test the search services module.""" @@ -285,6 +291,192 @@ def _mock_delete_docs(ids: List[str], index: str) -> None: self.assertEqual(delete_docs_counter.times_called, 1) + def test_index_exploration_summaries_with_translations(self) -> None: + exp = exp_domain.Exploration.create_default_exploration( + self.EXP_ID, + title='Exploration Title', + category='Category', + ) + exp.objective = 'Objective' + exp.tags = ['algebra', 'math'] + exp_services.save_new_exploration(self.owner_id, exp) + rights_manager.publish_exploration(self.owner, self.EXP_ID) + exp_summary = exp_fetchers.get_exploration_summary_by_id(self.EXP_ID) + + # 1. Test case: No translations. + indexed_docs: List[search_services.ExplorationSearchDict] = [] + + def mock_add_docs( + docs: List[search_services.ExplorationSearchDict], index: str + ) -> None: + self.assertEqual(index, search_services.SEARCH_INDEX_EXPLORATIONS) + indexed_docs.extend(docs) + + add_docs_counter = test_utils.CallCounter(mock_add_docs) + add_docs_swap = self.swap( + gae_search_services, 'add_documents_to_index', add_docs_counter + ) + + with add_docs_swap: + search_services.index_exploration_summaries([exp_summary]) + + self.assertEqual(add_docs_counter.times_called, 1) + self.assertEqual(len(indexed_docs), 1) + self.assertEqual(indexed_docs[0]['language_code'], ['en']) + self.assertEqual(indexed_docs[0]['translated_titles'], []) + self.assertEqual(indexed_docs[0]['translated_objectives'], []) + self.assertEqual(indexed_docs[0]['translated_tags'], []) + + # 2. Test case: Accepted translations. + translation_models.EntityTranslationsModel.create_new( + feconf.TranslatableEntityType.EXPLORATION.value, + self.EXP_ID, + 1, + 'hi', + { + feconf.EXPLORATION_TITLE_CONTENT_ID: { + 'content_value': 'translated title hi', + 'needs_update': False, + 'content_format': 'unicode', + }, + feconf.EXPLORATION_OBJECTIVE_CONTENT_ID: { + 'content_value': 'translated objective hi', + 'needs_update': False, + 'content_format': 'unicode', + }, + f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_0': { + 'content_value': 'translated tag hi', + 'needs_update': False, + 'content_format': 'unicode', + }, + }, + ).put() + + # Let's add a second language translation. + translation_models.EntityTranslationsModel.create_new( + feconf.TranslatableEntityType.EXPLORATION.value, + self.EXP_ID, + 1, + 'bn', + { + feconf.EXPLORATION_TITLE_CONTENT_ID: { + 'content_value': 'translated title bn', + 'needs_update': False, + 'content_format': 'unicode', + }, + # Stale translation (needs_update=True) for objective. + feconf.EXPLORATION_OBJECTIVE_CONTENT_ID: { + 'content_value': 'stale objective bn', + 'needs_update': True, + 'content_format': 'unicode', + }, + f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_0': { + # Duplicate tag translation. + 'content_value': 'translated tag hi', + 'needs_update': False, + 'content_format': 'unicode', + }, + f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_1': { + 'content_value': 'translated tag bn', + 'needs_update': False, + 'content_format': 'unicode', + }, + }, + ).put() + + indexed_docs = [] + add_docs_counter = test_utils.CallCounter(mock_add_docs) + add_docs_swap = self.swap( + gae_search_services, 'add_documents_to_index', add_docs_counter + ) + + with add_docs_swap: + search_services.index_exploration_summaries([exp_summary]) + + self.assertEqual(add_docs_counter.times_called, 1) + self.assertEqual(len(indexed_docs), 1) + # Verify title translations (sorted, deduplicated). + self.assertEqual( + indexed_docs[0]['translated_titles'], + ['translated title bn', 'translated title hi'], + ) + # Verify objective translations (stale translation excluded). + self.assertEqual( + indexed_docs[0]['translated_objectives'], + ['translated objective hi'], + ) + # Verify tag translations (sorted, deduplicated, stale/untranslated excluded). + self.assertEqual( + indexed_docs[0]['translated_tags'], + ['translated tag bn', 'translated tag hi'], + ) + # Verify language codes (original + languages with valid translations, sorted). + self.assertEqual( + indexed_docs[0]['language_code'], + ['bn', 'en', 'hi'], + ) + + # 3. Test case: Exploration not found in explorations_dict (defensive check). + indexed_docs = [] + add_docs_counter = test_utils.CallCounter(mock_add_docs) + add_docs_swap = self.swap( + gae_search_services, 'add_documents_to_index', add_docs_counter + ) + get_multiple_swap = self.swap( + exp_fetchers, + 'get_multiple_explorations_by_id', + lambda *args, **kwargs: {}, + ) + + with add_docs_swap, get_multiple_swap: + search_services.index_exploration_summaries([exp_summary]) + + self.assertEqual(add_docs_counter.times_called, 1) + self.assertEqual(len(indexed_docs), 1) + self.assertEqual(indexed_docs[0]['language_code'], ['en']) + self.assertEqual(indexed_docs[0]['translated_titles'], []) + self.assertEqual(indexed_docs[0]['translated_objectives'], []) + self.assertEqual(indexed_docs[0]['translated_tags'], []) + + def test_index_exploration_summaries_batches_explorations_lookups( + self, + ) -> None: + # Create multiple explorations. + self.save_new_valid_exploration( + 'exp_id_1', + self.owner_id, + title='Title 1', + category='Category', + ) + rights_manager.publish_exploration(self.owner, 'exp_id_1') + self.save_new_valid_exploration( + 'exp_id_2', + self.owner_id, + title='Title 2', + category='Category', + ) + rights_manager.publish_exploration(self.owner, 'exp_id_2') + summaries = [ + exp_fetchers.get_exploration_summary_by_id('exp_id_1'), + exp_fetchers.get_exploration_summary_by_id('exp_id_2'), + ] + + # Use CallCounter to verify how many times get_multiple_explorations_by_id is called. + orig_get_multiple = exp_fetchers.get_multiple_explorations_by_id + get_multiple_counter = test_utils.CallCounter(orig_get_multiple) + + get_multiple_swap = self.swap( + exp_fetchers, + 'get_multiple_explorations_by_id', + get_multiple_counter, + ) + + with get_multiple_swap: + search_services.index_exploration_summaries(summaries) + + # It should be called exactly once, not N times (where N = 2). + self.assertEqual(get_multiple_counter.times_called, 1) + class BlogPostSearchServicesUnitTests(test_utils.GenericTestBase): diff --git a/core/domain/skill_services.py b/core/domain/skill_services.py index da08626252500..6ce5d7b04f698 100644 --- a/core/domain/skill_services.py +++ b/core/domain/skill_services.py @@ -37,6 +37,7 @@ topic_domain, topic_fetchers, topic_services, + translation_fetchers, translation_services, user_services, ) @@ -61,6 +62,7 @@ question_models, skill_models, topic_models, + translation_models, user_models, ) @@ -1773,3 +1775,94 @@ def get_categorized_skill_ids_and_descriptions() -> ( ) return categorized_skills + + +class ConceptCardDict(skill_domain.SkillContentsDict): + """Dictionary representing a concept card shown to a learner. It carries + the skill description alongside the skill contents, because both are + displayed on the card and both are translatable. + """ + + skill_description: str + + +def get_concept_card_dicts( + skills: List[skill_domain.Skill], + language_code: Optional[str] = None, +) -> List[ConceptCardDict]: + """Returns the concept card dicts for the given skills, with the skill + description and explanation translated into the given language where a + translation exists. + + Args: + skills: list(Skill). The skills whose concept cards are required. + language_code: str or None. The language code to display the concept + cards in. If None or English, the original content is returned. + + Returns: + list(ConceptCardDict). The concept card dicts for the given skills. + Any content without an up-to-date translation falls back to English. + """ + concept_card_dicts: List[ConceptCardDict] = [] + for skill in skills: + skill_contents_dict = skill.skill_contents.to_dict() + # Every key of SkillContentsDict is listed explicitly because mypy + # rejects a ** spread into a TypedDict, so a field added to + # SkillContentsDict has to be added here as well. + concept_card_dicts.append( + { + 'explanation': skill_contents_dict['explanation'], + 'recorded_voiceovers': skill_contents_dict[ + 'recorded_voiceovers' + ], + 'written_translations': skill_contents_dict[ + 'written_translations' + ], + 'skill_description': skill.description, + } + ) + + if ( + language_code is None + or language_code == constants.DEFAULT_LANGUAGE_CODE + ): + return concept_card_dicts + + entity_references: List[ + translation_models.EntityTranslationReferenceDict + ] = [ + { + 'entity_type': feconf.TranslatableEntityType.SKILL, + 'entity_id': skill.id, + 'entity_version': skill.version, + 'language_code': language_code, + } + for skill in skills + ] + entity_translations = translation_fetchers.get_multiple_entity_translations( + entity_references + ) + + for skill, concept_card_dict, entity_translation in zip( + skills, concept_card_dicts, entity_translations + ): + if entity_translation is None: + continue + + translated_description = ( + translation_services.get_up_to_date_translation( + entity_translation, feconf.SKILL_DESCRIPTION_CONTENT_ID + ) + ) + if translated_description is not None: + concept_card_dict['skill_description'] = translated_description + + translated_explanation = ( + translation_services.get_up_to_date_translation( + entity_translation, skill.skill_contents.explanation.content_id + ) + ) + if translated_explanation is not None: + concept_card_dict['explanation']['html'] = translated_explanation + + return concept_card_dicts diff --git a/core/domain/suggestion_services.py b/core/domain/suggestion_services.py index 4b0bf4628e266..4e7999f86fcc3 100644 --- a/core/domain/suggestion_services.py +++ b/core/domain/suggestion_services.py @@ -1007,6 +1007,12 @@ def accept_suggestion( suggestion.change_cmd.language_code, suggestion.target_type, ) + if suggestion.target_type == feconf.ENTITY_TYPE_EXPLORATION: + # We import exp_services locally here to avoid a circular dependency + # with exp_services, which imports suggestion_services at the top level. + from core.domain import exp_services + + exp_services.index_explorations_given_ids([suggestion.target_id]) # Update the community contribution stats so that the number of suggestions # of this type that are in review decreases by one, since this diff --git a/core/domain/suggestion_services_test.py b/core/domain/suggestion_services_test.py index 84b6bc827c8bc..2652d5b28489c 100644 --- a/core/domain/suggestion_services_test.py +++ b/core/domain/suggestion_services_test.py @@ -1299,6 +1299,156 @@ def test_accept_suggestion_successfully(self) -> None: last_message = thread_messages[len(thread_messages) - 1] self.assertEqual(last_message.text, 'review message') + def test_accept_exploration_translation_suggestion_reindexes_exploration( + self, + ) -> None: + """Test that accepting a translation suggestion for an exploration + triggers exploration reindexing. + """ + # Create an exploration with the content. + exp = exp_domain.Exploration.create_default_exploration( + self.target_id, title='Title', category='Category' + ) + exp.states['Introduction'].update_content( + state_domain.SubtitledHtml('content_0', '

The content html

') + ) + exp_services.save_new_exploration(self.author_id, exp) + caching_services.flush_memory_caches() + + change_dict = { + 'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION, + 'state_name': 'Introduction', + 'content_id': 'content_0', + 'language_code': 'hi', + 'content_html': exp.states['Introduction'].content.html, + 'translation_html': '

Updated translation.

', + 'data_format': 'html', + } + + suggestion_services.create_suggestion( + feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + feconf.ENTITY_TYPE_EXPLORATION, + self.target_id, + exp.version, + self.author_id, + change_dict, + 'test description', + ) + suggestion = suggestion_services.query_suggestions( + [('author_id', self.author_id), ('target_id', self.target_id)] + )[0] + + reindexed_ids = [] + + def mock_index_explorations(ids: List[str]) -> None: + reindexed_ids.extend(ids) + + index_counter = test_utils.CallCounter(mock_index_explorations) + + with ( + self.swap( + suggestion_registry.SuggestionTranslateContent, + 'pre_accept_validate', + lambda self: None, + ), + self.swap( + suggestion_registry.SuggestionTranslateContent, + 'accept', + lambda self, unused_commit_message: None, + ), + self.swap( + opportunity_services, + 'update_translation_opportunity_with_accepted_suggestion', + lambda *args: None, + ), + self.swap( + exp_services, + 'index_explorations_given_ids', + index_counter, + ), + ): + suggestion_services.accept_suggestion( + suggestion.suggestion_id, + self.reviewer_id, + self.COMMIT_MESSAGE, + 'review message', + ) + + self.assertEqual(index_counter.times_called, 1) + self.assertEqual(reindexed_ids, [self.target_id]) + + def test_accept_skill_translation_suggestion_does_not_reindex(self) -> None: + """Test that accepting a translation suggestion for a skill does not + trigger exploration reindexing. + """ + skill_id = skill_services.get_new_skill_id() + skill_description = 'Skill Description' + 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_services.create_suggestion( + feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + feconf.ENTITY_TYPE_SKILL, + skill_id, + 1, + self.author_id, + change_dict, + 'test description', + ) + suggestion = suggestion_services.query_suggestions( + [('author_id', self.author_id), ('target_id', skill_id)] + )[0] + + reindexed_ids = [] + + def mock_index_explorations(ids: List[str]) -> None: + reindexed_ids.extend(ids) + + index_counter = test_utils.CallCounter(mock_index_explorations) + + with ( + self.swap( + suggestion_registry.SuggestionTranslateContent, + 'pre_accept_validate', + lambda self: None, + ), + self.swap( + suggestion_registry.SuggestionTranslateContent, + 'accept', + lambda self, unused_commit_message: None, + ), + self.swap( + opportunity_services, + 'update_translation_opportunity_with_accepted_suggestion', + lambda *args: None, + ), + self.swap( + exp_services, + 'index_explorations_given_ids', + index_counter, + ), + ): + suggestion_services.accept_suggestion( + suggestion.suggestion_id, + self.reviewer_id, + self.COMMIT_MESSAGE, + 'review message', + ) + + self.assertEqual(index_counter.times_called, 0) + self.assertEqual(reindexed_ids, []) + def test_accept_suggestion_raises_exception_if_suggestion_does_not_exist( self, ) -> None: diff --git a/core/domain/summary_services.py b/core/domain/summary_services.py index 49e62a7d519e4..e8298c06918a6 100644 --- a/core/domain/summary_services.py +++ b/core/domain/summary_services.py @@ -18,7 +18,7 @@ from __future__ import annotations -from core import utils +from core import feconf, utils from core.constants import constants from core.domain import ( activity_domain, @@ -32,6 +32,8 @@ rights_manager, search_services, stats_services, + translation_domain, + translation_fetchers, user_domain, user_services, ) @@ -48,6 +50,10 @@ cast, ) +MYPY = False +if MYPY: # pragma: no cover + from mypy_imports import translation_models + class DisplayableCollectionSummaryDict(TypedDict): """Type for the displayable collection summary dictionary.""" @@ -86,6 +92,7 @@ class DisplayableExplorationSummaryDict(TypedDict): visited_checkpoints_count: int total_checkpoints_count: int num_views: int + translated_metadata_fields: List[str] class PlaythroughDict(TypedDict): @@ -472,6 +479,7 @@ def get_exploration_metadata_dicts( def get_displayable_exp_summary_dicts_matching_ids( exploration_ids: List[str], user: Optional[user_domain.UserActionsInfo] = None, + display_in_language_code: Optional[str] = None, ) -> List[DisplayableExplorationSummaryDict]: """Gets a summary of explorations in human readable form from exploration ids. @@ -488,6 +496,8 @@ def get_displayable_exp_summary_dicts_matching_ids( exploration_ids: list(str). List of exploration ids. user: UserActionsInfo or None. Object having user_id, role and actions for given user. + display_in_language_code: str or None. The language code in which to + display translated metadata. Returns: list(dict). A list of exploration summary dicts in human readable form. @@ -532,11 +542,58 @@ def get_displayable_exp_summary_dicts_matching_ids( filtered_exploration_summaries.append(exploration_summary) - return get_displayable_exp_summary_dicts(filtered_exploration_summaries) + return get_displayable_exp_summary_dicts( + filtered_exploration_summaries, + display_in_language_code=display_in_language_code, + ) + + +EXPLORATION_SUMMARY_TITLE_FIELD = 'title' +EXPLORATION_SUMMARY_OBJECTIVE_FIELD = 'objective' +EXPLORATION_SUMMARY_CATEGORY_FIELD = 'category' +EXPLORATION_SUMMARY_TAGS_FIELD = 'tags' + +# Maps each single-valued translatable field of a displayable exploration +# summary dict to the content ID its translation is stored under. Tags are +# handled separately because each tag has its own content ID. +EXPLORATION_SUMMARY_FIELD_TO_CONTENT_ID = { + EXPLORATION_SUMMARY_TITLE_FIELD: feconf.EXPLORATION_TITLE_CONTENT_ID, + EXPLORATION_SUMMARY_OBJECTIVE_FIELD: ( + feconf.EXPLORATION_OBJECTIVE_CONTENT_ID + ), + EXPLORATION_SUMMARY_CATEGORY_FIELD: feconf.EXPLORATION_CATEGORY_CONTENT_ID, +} + + +def _get_up_to_date_translation( + entity_translation: translation_domain.EntityTranslation, + content_id: str, +) -> Optional[str]: + """Returns the translation of the given content ID, if it is usable. + + Args: + entity_translation: EntityTranslation. The entity's translations. + content_id: str. The content ID to look up. + + Returns: + str or None. The translated value, or None when there is no translation + or the translation is stale. + """ + translated_content = entity_translation.translations.get(content_id) + # A translation that needs an update is stale because the English content + # changed after it was accepted, so it is not shown. + if ( + translated_content is not None + and not translated_content.needs_update + and isinstance(translated_content.content_value, str) + ): + return translated_content.content_value + return None def get_displayable_exp_summary_dicts( exploration_summaries: List[exp_domain.ExplorationSummary], + display_in_language_code: Optional[str] = None, ) -> List[DisplayableExplorationSummaryDict]: """Gets a summary of explorations in human readable form. @@ -549,6 +606,8 @@ def get_displayable_exp_summary_dicts( Args: exploration_summaries: list(ExplorationSummary). List of exploration summary objects. + display_in_language_code: str or None. The language code in which to + display translated metadata. Returns: list(dict). A list of exploration summary dicts in human readable form. @@ -578,19 +637,104 @@ def get_displayable_exp_summary_dicts( ) view_counts = [exp_stats.num_starts for exp_stats in exp_stats_list] + entity_translations_map: Dict[str, translation_domain.EntityTranslation] = ( + {} + ) + if ( + display_in_language_code + and display_in_language_code != constants.DEFAULT_LANGUAGE_CODE + ): + # Translations are stored against the exploration's own version, and a + # summary's version can run ahead of it, so the version is read from + # the exploration rather than from the summary. This fetch only happens + # when a non-English display language is requested. + exp_ids = [ + exp_summary.id + for exp_summary in exploration_summaries + if exp_summary is not None + ] + explorations = exp_fetchers.get_multiple_explorations_by_id( + exp_ids, strict=False + ) + translated_exp_ids = [ + exp_id for exp_id in exp_ids if exp_id in explorations + ] + entity_references: List[ + translation_models.EntityTranslationReferenceDict + ] = [ + { + 'entity_type': feconf.TranslatableEntityType.EXPLORATION, + 'entity_id': exp_id, + 'entity_version': explorations[exp_id].version, + 'language_code': display_in_language_code, + } + for exp_id in translated_exp_ids + ] + entity_translations_list = ( + translation_fetchers.get_multiple_entity_translations( + entity_references + ) + ) + for exp_id, entity_translation in zip( + translated_exp_ids, entity_translations_list + ): + if entity_translation is not None: + entity_translations_map[exp_id] = entity_translation + displayable_exp_summaries = [] for ind, exploration_summary in enumerate(exploration_summaries): if exploration_summary: + translated_values: Dict[str, str] = {} + translated_metadata_fields: List[str] = [] + tags = list(exploration_summary.tags) + + entity_translation = entity_translations_map.get( + exploration_summary.id + ) + if entity_translation is not None: + for ( + field_name, + content_id, + ) in EXPLORATION_SUMMARY_FIELD_TO_CONTENT_ID.items(): + translated_value = _get_up_to_date_translation( + entity_translation, content_id + ) + if translated_value is not None: + translated_values[field_name] = translated_value + translated_metadata_fields.append(field_name) + + # Tag content IDs are index based, matching how they are + # generated in Exploration.get_translatable_contents_collection. + for tag_idx, _ in enumerate(exploration_summary.tags): + translated_tag = _get_up_to_date_translation( + entity_translation, + f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_{tag_idx}', + ) + if translated_tag is not None: + tags[tag_idx] = translated_tag + if tags != exploration_summary.tags: + translated_metadata_fields.append( + EXPLORATION_SUMMARY_TAGS_FIELD + ) + summary_dict: DisplayableExplorationSummaryDict = { 'id': exploration_summary.id, - 'title': exploration_summary.title, + 'title': translated_values.get( + EXPLORATION_SUMMARY_TITLE_FIELD, exploration_summary.title + ), 'activity_type': constants.ACTIVITY_TYPE_EXPLORATION, - 'category': exploration_summary.category, + 'category': translated_values.get( + EXPLORATION_SUMMARY_CATEGORY_FIELD, + exploration_summary.category, + ), 'created_on_msec': utils.get_time_in_millisecs( exploration_summary.exploration_model_created_on ), - 'objective': exploration_summary.objective, + 'objective': translated_values.get( + EXPLORATION_SUMMARY_OBJECTIVE_FIELD, + exploration_summary.objective, + ), 'language_code': exploration_summary.language_code, 'last_updated_msec': utils.get_time_in_millisecs( exploration_summary.exploration_model_last_updated @@ -603,7 +747,7 @@ def get_displayable_exp_summary_dicts( 'status': exploration_summary.status, 'ratings': exploration_summary.ratings, 'community_owned': exploration_summary.community_owned, - 'tags': exploration_summary.tags, + 'tags': tags, 'thumbnail_icon_url': utils.get_thumbnail_icon_url_for_category( exploration_summary.category ), @@ -613,6 +757,10 @@ def get_displayable_exp_summary_dicts( 'num_views': view_counts[ind], 'visited_checkpoints_count': 0, 'total_checkpoints_count': 0, + # Clients fall back to their own hardcoded translation bundles + # for the fields that are not listed here, so the list has to + # say which values actually came back translated. + 'translated_metadata_fields': translated_metadata_fields, } displayable_exp_summaries.append(summary_dict) @@ -683,13 +831,19 @@ def _get_displayable_collection_summary_dicts( return displayable_collection_summaries -def get_library_groups(language_codes: List[str]) -> List[LibraryGroupDict]: +def get_library_groups( + language_codes: List[str], + display_in_language_code: Optional[str] = None, +) -> List[LibraryGroupDict]: """Returns a list of groups for the library index page. Each group has a header and a list of dicts representing activity summaries. Args: language_codes: list(str). A list of language codes. Only explorations with these languages will be returned. + display_in_language_code: str or None. The language code to display + translated exploration metadata in. If None, the original metadata + is returned. Returns: list(dict). A list of groups for the library index page. Each group is @@ -750,7 +904,9 @@ def get_library_groups(language_codes: List[str]) -> List[LibraryGroupDict]: exp_summary_dicts = { summary_dict['id']: summary_dict - for summary_dict in get_displayable_exp_summary_dicts(exp_summaries) + for summary_dict in get_displayable_exp_summary_dicts( + exp_summaries, display_in_language_code=display_in_language_code + ) } results: List[LibraryGroupDict] = [] @@ -873,6 +1029,7 @@ def check_activity_id_validity( def get_featured_activity_summary_dicts( language_codes: List[str], + display_in_language_code: Optional[str] = None, ) -> List[DisplayableSummaryDictsType]: """Returns a list of featured activities with the given language codes. The return value is sorted according to the list stored in the datastore. @@ -880,6 +1037,9 @@ def get_featured_activity_summary_dicts( Args: language_codes: list(str). A list of language codes. Only explorations with these languages will be returned. + display_in_language_code: str or None. The language code to display + translated exploration metadata in. If None, the original metadata + is returned. Returns: list(dict). Each dict in this list represents a featured activity. @@ -907,7 +1067,7 @@ def get_featured_activity_summary_dicts( ) exp_summary_dicts = get_displayable_exp_summary_dicts_matching_ids( - exploration_ids + exploration_ids, display_in_language_code=display_in_language_code ) col_summary_dicts = get_displayable_collection_summary_dicts_matching_ids( collection_ids @@ -934,7 +1094,9 @@ def get_featured_activity_summary_dicts( def get_top_rated_exploration_summary_dicts( - language_codes: List[str], limit: int + language_codes: List[str], + limit: int, + display_in_language_code: Optional[str] = None, ) -> List[DisplayableExplorationSummaryDict]: """Returns a list of top rated explorations with the given language codes. The return value is sorted in decreasing order of average rating. @@ -943,6 +1105,9 @@ def get_top_rated_exploration_summary_dicts( language_codes: list(str). A list of language codes. Only explorations with these languages will be returned. limit: int. The maximum number of explorations to return. + display_in_language_code: str or None. The language code to display + translated exploration metadata in. If None, the original metadata + is returned. Returns: list(dict). Each dict in this list represents a exploration summary in @@ -980,16 +1145,22 @@ def get_top_rated_exploration_summary_dicts( filtered_exp_summaries, key=sort_fnc, reverse=True ) - return get_displayable_exp_summary_dicts(sorted_exp_summaries) + return get_displayable_exp_summary_dicts( + sorted_exp_summaries, display_in_language_code=display_in_language_code + ) def get_recently_published_exp_summary_dicts( limit: int, + display_in_language_code: Optional[str] = None, ) -> List[DisplayableExplorationSummaryDict]: """Returns a list of recently published explorations. Args: limit: int. The maximum number of explorations to return. + display_in_language_code: str or None. The language code to display + translated exploration metadata in. If None, the original metadata + is returned. Returns: list(dict). Each dict in this list represents a featured activity in @@ -1027,4 +1198,6 @@ def get_recently_published_exp_summary_dicts( recently_published_exploration_summaries, key=sort_fnc, reverse=True ) - return get_displayable_exp_summary_dicts(summaries) + return get_displayable_exp_summary_dicts( + summaries, display_in_language_code=display_in_language_code + ) diff --git a/core/domain/summary_services_test.py b/core/domain/summary_services_test.py index 52d1ca82e843d..3152de9eb35ff 100644 --- a/core/domain/summary_services_test.py +++ b/core/domain/summary_services_test.py @@ -26,12 +26,15 @@ collection_domain, collection_services, exp_domain, + exp_fetchers, exp_services, exp_services_test, rating_services, rights_domain, rights_manager, summary_services, + translation_domain, + translation_services, user_services, ) from core.tests import test_utils @@ -266,6 +269,7 @@ def test_get_displayable_exp_summary_dicts_matching_ids(self) -> None: 'thumbnail_bg_color': '#cc4b00', 'thumbnail_icon_url': '/subjects/Algebra.svg', 'title': 'Exploration 2 Albert title', + 'translated_metadata_fields': [], } self.assertIn('last_updated_msec', displayable_summaries[0]) self.assertDictContainsSubset( @@ -282,6 +286,153 @@ def test_get_displayable_exp_summary_dicts_matching_ids_with_invalid_exp_id( ) self.assertEqual(displayable_summaries, []) + def test_get_displayable_exp_summary_dicts_with_translated_metadata( + self, + ) -> None: + exp_summary_1 = exp_fetchers.get_exploration_summary_by_id( + self.EXP_ID_1 + ) + exp_summary_2 = exp_fetchers.get_exploration_summary_by_id( + self.EXP_ID_2 + ) + exp_summary_2.tags = ['tag', 'math'] + + translated_title = translation_domain.TranslatedContent( + 'Exploration 2 Hindi Title', + translation_domain.TranslatableContentFormat.UNICODE_STRING, + needs_update=False, + ) + translated_objective = translation_domain.TranslatedContent( + 'Exploration 2 Hindi Objective', + translation_domain.TranslatableContentFormat.UNICODE_STRING, + needs_update=False, + ) + translated_category = translation_domain.TranslatedContent( + 'Exploration 2 Hindi Category', + translation_domain.TranslatableContentFormat.UNICODE_STRING, + needs_update=False, + ) + translated_tag_0 = translation_domain.TranslatedContent( + 'Hindi Tag 1', + translation_domain.TranslatableContentFormat.UNICODE_STRING, + needs_update=False, + ) + translated_tag_1_outdated = translation_domain.TranslatedContent( + 'Outdated Tag 2', + translation_domain.TranslatableContentFormat.UNICODE_STRING, + needs_update=True, + ) + + translation_services.add_new_translation( + feconf.TranslatableEntityType.EXPLORATION, + self.EXP_ID_2, + exp_summary_2.version, + 'hi', + feconf.EXPLORATION_TITLE_CONTENT_ID, + translated_title, + ) + translation_services.add_new_translation( + feconf.TranslatableEntityType.EXPLORATION, + self.EXP_ID_2, + exp_summary_2.version, + 'hi', + feconf.EXPLORATION_OBJECTIVE_CONTENT_ID, + translated_objective, + ) + translation_services.add_new_translation( + feconf.TranslatableEntityType.EXPLORATION, + self.EXP_ID_2, + exp_summary_2.version, + 'hi', + feconf.EXPLORATION_CATEGORY_CONTENT_ID, + translated_category, + ) + translation_services.add_new_translation( + feconf.TranslatableEntityType.EXPLORATION, + self.EXP_ID_2, + exp_summary_2.version, + 'hi', + f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_0', + translated_tag_0, + ) + translation_services.add_new_translation( + feconf.TranslatableEntityType.EXPLORATION, + self.EXP_ID_2, + exp_summary_2.version, + 'hi', + f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_1', + translated_tag_1_outdated, + ) + + # Querying with exp_summary_1 (no translations) and exp_summary_2 (has translations). + displayable_summaries = ( + summary_services.get_displayable_exp_summary_dicts( + [exp_summary_1, exp_summary_2], display_in_language_code='hi' + ) + ) + self.assertEqual(len(displayable_summaries), 2) + + # exp_summary_1 keeps untranslated default metadata. + exp1_summary = next( + s for s in displayable_summaries if s['id'] == self.EXP_ID_1 + ) + self.assertEqual(exp1_summary['title'], 'Exploration 1 title') + self.assertEqual(exp1_summary['translated_metadata_fields'], []) + + # exp_summary_2 receives translated title, objective, category, and tag 0. + exp2_summary = next( + s for s in displayable_summaries if s['id'] == self.EXP_ID_2 + ) + self.assertEqual(exp2_summary['title'], 'Exploration 2 Hindi Title') + self.assertEqual( + exp2_summary['objective'], 'Exploration 2 Hindi Objective' + ) + self.assertEqual( + exp2_summary['category'], 'Exploration 2 Hindi Category' + ) + self.assertEqual(exp2_summary['tags'], ['Hindi Tag 1', 'math']) + self.assertEqual( + exp2_summary['translated_metadata_fields'], + ['title', 'objective', 'category', 'tags'], + ) + + def test_get_displayable_exp_summary_dicts_with_partially_translated_metadata( + self, + ) -> None: + exp_summary_2 = exp_fetchers.get_exploration_summary_by_id( + self.EXP_ID_2 + ) + exp_summary_2.tags = ['tag1'] + + translated_title = translation_domain.TranslatedContent( + 'Exploration 2 Hindi Title Only', + translation_domain.TranslatableContentFormat.UNICODE_STRING, + needs_update=False, + ) + + translation_services.add_new_translation( + feconf.TranslatableEntityType.EXPLORATION, + self.EXP_ID_2, + exp_summary_2.version, + 'hi', + feconf.EXPLORATION_TITLE_CONTENT_ID, + translated_title, + ) + + displayable_summaries = ( + summary_services.get_displayable_exp_summary_dicts( + [exp_summary_2], display_in_language_code='hi' + ) + ) + self.assertEqual(len(displayable_summaries), 1) + self.assertEqual( + displayable_summaries[0]['translated_metadata_fields'], ['title'] + ) + self.assertEqual( + displayable_summaries[0]['title'], 'Exploration 2 Hindi Title Only' + ) + self.assertEqual(displayable_summaries[0]['tags'], ['tag1']) + def test_get_public_and_filtered_private_summary_dicts_for_creator( self, ) -> None: @@ -1078,6 +1229,40 @@ def test_for_recently_published_explorations(self) -> None: test_summary_3, recently_published_exploration_summaries[0] ) + def test_get_recently_published_exp_summary_dicts_with_display_in_language_code( + self, + ) -> None: + exp_summary_1 = exp_fetchers.get_exploration_summary_by_id( + self.EXP_ID_1 + ) + translated_title = translation_domain.TranslatedContent( + 'Recently Published Hindi Title', + translation_domain.TranslatableContentFormat.UNICODE_STRING, + needs_update=False, + ) + translation_services.add_new_translation( + feconf.TranslatableEntityType.EXPLORATION, + self.EXP_ID_1, + exp_summary_1.version, + 'hi', + feconf.EXPLORATION_TITLE_CONTENT_ID, + translated_title, + ) + + recently_published_summaries = ( + summary_services.get_recently_published_exp_summary_dicts( + feconf.RECENTLY_PUBLISHED_QUERY_LIMIT_FOR_LIBRARY_PAGE, + display_in_language_code='hi', + ) + ) + exp1_summary = next( + s for s in recently_published_summaries if s['id'] == self.EXP_ID_1 + ) + self.assertEqual( + exp1_summary['title'], 'Recently Published Hindi Title' + ) + self.assertEqual(exp1_summary['translated_metadata_fields'], ['title']) + class ActivityReferenceAccessCheckerTests(test_utils.GenericTestBase): """Tests for checking id validity of activities diff --git a/core/domain/taskqueue_services.py b/core/domain/taskqueue_services.py index eba1590f9b69c..763b5cc5b622c 100644 --- a/core/domain/taskqueue_services.py +++ b/core/domain/taskqueue_services.py @@ -258,6 +258,9 @@ def update_cloud_task_run_model( cloud_task_model.current_retry_attempt = ( cloud_task_run_domain_instance.current_retry_attempt ) + cloud_task_model.additional_contextual_information = ( + cloud_task_run_domain_instance.additional_contextual_information + ) cloud_task_model.update_timestamps() cloud_task_model.put() @@ -353,6 +356,9 @@ def convert_cloud_task_run_model_to_domain_object( 'current_retry_attempt': cloud_task_model.current_retry_attempt, 'last_updated': cloud_task_model.last_updated.isoformat(), 'created_on': cloud_task_model.created_on.isoformat(), + 'additional_contextual_information': ( + cloud_task_model.additional_contextual_information + ), } cloud_task_run = cloud_task_domain.CloudTaskRun.from_dict(model_dict) return cloud_task_run @@ -388,6 +394,12 @@ def get_cloud_task_run_by_given_params( <= model.last_updated.replace(tzinfo=datetime.timezone.utc) <= end_datetime ) + and ( + model.function_id + != feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ + 'FUNCTION_ID_REGENERATE_VOICEOVERS_FOR_BATCH_CONTENTS' + ] + ) ] return [ convert_cloud_task_run_model_to_domain_object(model) diff --git a/core/domain/translation_services.py b/core/domain/translation_services.py index 4e5234b257d56..adcc93a58ac11 100644 --- a/core/domain/translation_services.py +++ b/core/domain/translation_services.py @@ -485,3 +485,29 @@ def save_machine_translation_provider_mapping( model.language_to_provider_mapping = language_to_provider_mapping model.update_timestamps() model.put() + + +def get_up_to_date_translation( + entity_translation: translation_domain.EntityTranslation, + content_id: str, +) -> Optional[str]: + """Returns the translation of the given content ID, if it is usable. + + Args: + entity_translation: EntityTranslation. The entity's translations. + content_id: str. The content ID to look up. + + Returns: + str or None. The translated value, or None when there is no translation + or the translation is stale. + """ + translated_content = entity_translation.translations.get(content_id) + # A translation that needs an update is stale because the English content + # changed after it was accepted, so it is not shown. + if ( + translated_content is not None + and not translated_content.needs_update + and isinstance(translated_content.content_value, str) + ): + return translated_content.content_value + return None diff --git a/core/domain/translation_services_test.py b/core/domain/translation_services_test.py index deb58552827eb..37d78d9a800bd 100644 --- a/core/domain/translation_services_test.py +++ b/core/domain/translation_services_test.py @@ -970,3 +970,90 @@ def test_save_machine_translation_provider_mapping_raises_error( translation_services.save_machine_translation_provider_mapping( invalid_mapping ) + + def test_get_up_to_date_translation_returns_translation_successfully( + self, + ) -> None: + translation_dict = { + 'content_id_1': translation_domain.TranslatedContent( + 'Valid translation', + translation_domain.TranslatableContentFormat.HTML, + False, + ) + } + entity_translation = translation_domain.EntityTranslation( + 'exp_id', + feconf.TranslatableEntityType.EXPLORATION, + 1, + 'hi', + translation_dict, + ) + self.assertEqual( + translation_services.get_up_to_date_translation( + entity_translation, 'content_id_1' + ), + 'Valid translation', + ) + + def test_get_up_to_date_translation_missing_id_returns_none(self) -> None: + translation_dict = { + 'content_id_1': translation_domain.TranslatedContent( + 'Valid translation', + translation_domain.TranslatableContentFormat.HTML, + False, + ) + } + entity_translation = translation_domain.EntityTranslation( + 'exp_id', + feconf.TranslatableEntityType.EXPLORATION, + 1, + 'hi', + translation_dict, + ) + self.assertIsNone( + translation_services.get_up_to_date_translation( + entity_translation, 'content_id_2' + ) + ) + + def test_get_up_to_date_translation_stale_returns_none(self) -> None: + translation_dict = { + 'content_id_1': translation_domain.TranslatedContent( + 'Stale translation', + translation_domain.TranslatableContentFormat.HTML, + True, + ) + } + entity_translation = translation_domain.EntityTranslation( + 'exp_id', + feconf.TranslatableEntityType.EXPLORATION, + 1, + 'hi', + translation_dict, + ) + self.assertIsNone( + translation_services.get_up_to_date_translation( + entity_translation, 'content_id_1' + ) + ) + + def test_get_up_to_date_translation_non_string_returns_none(self) -> None: + translation_dict = { + 'content_id_1': translation_domain.TranslatedContent( + ['List translation'], + translation_domain.TranslatableContentFormat.SET_OF_UNICODE_STRING, + False, + ) + } + entity_translation = translation_domain.EntityTranslation( + 'exp_id', + feconf.TranslatableEntityType.EXPLORATION, + 1, + 'hi', + translation_dict, + ) + self.assertIsNone( + translation_services.get_up_to_date_translation( + entity_translation, 'content_id_1' + ) + ) diff --git a/core/domain/voiceover_services.py b/core/domain/voiceover_services.py index 5bb44860917bd..9e324eecc5031 100644 --- a/core/domain/voiceover_services.py +++ b/core/domain/voiceover_services.py @@ -33,6 +33,7 @@ state_domain, suggestion_services, taskqueue_services, + topic_fetchers, translation_domain, translation_fetchers, user_services, @@ -47,13 +48,14 @@ MYPY = False if MYPY: # pragma: no cover - from mypy_imports import exp_models, voiceover_models + from mypy_imports import exp_models, opportunity_models, voiceover_models ( exp_models, + opportunity_models, voiceover_models, ) = models.Registry.import_models( - [models.Names.EXPLORATION, models.Names.VOICEOVER] + [models.Names.EXPLORATION, models.Names.OPPORTUNITY, models.Names.VOICEOVER] ) @@ -974,6 +976,7 @@ def regenerate_voiceovers_for_given_contents( exploration_version: int, language_code_to_contents_mapping: Dict[str, Dict[str, str]], task_run_id: str, + additional_contextual_information: Dict[str, str], specific_language_accent_code: Optional[str] = None, ) -> None: """Helper method to regenerate voiceovers for specified contents @@ -989,6 +992,8 @@ def regenerate_voiceovers_for_given_contents( that require voiceover regeneration. task_run_id: str. The unique identifier for the voiceover regeneration task. + additional_contextual_information: dict. A dictionary containing additional + contextual information related to the voiceover regeneration process. specific_language_accent_code: Optional[str]. The specific language accent code to use for voiceover regeneration, if provided. """ @@ -999,6 +1004,8 @@ def regenerate_voiceovers_for_given_contents( # support autogeneration. language_code_to_autogeneratable_accent_codes = {} + language_accent_codes_for_voiceover_regenerations = [] + # Retrieve all Oppia-supported language accents, grouped by language code, # for which voiceovers need to be regenerated for the given contents. for language_code in language_codes: @@ -1026,6 +1033,23 @@ def regenerate_voiceovers_for_given_contents( language_code_to_autogeneratable_accent_codes, ) + language_accent_codes_to_descriptions = ( + get_language_accent_codes_to_descriptions() + ) + + for ( + language_code, + accent_codes, + ) in language_code_to_autogeneratable_accent_codes.items(): + for accent_code in accent_codes: + language_accent_codes_for_voiceover_regenerations.append( + language_accent_codes_to_descriptions[accent_code] + ) + + additional_contextual_information['language_accents'] = ', '.join( + language_accent_codes_for_voiceover_regenerations + ) + # Ruling out the possibility of None for mypy type checking. assert voiceover_regeneration_job is not None @@ -1033,6 +1057,18 @@ def regenerate_voiceovers_for_given_contents( voiceover_regeneration_job ) + parent_cloud_task_run = taskqueue_services.get_cloud_task_run_by_model_id( + task_run_id + ) + # Ruling out the possibility of None for mypy type checking. + assert parent_cloud_task_run is not None + + parent_cloud_task_run.additional_contextual_information = ( + additional_contextual_information + ) + + taskqueue_services.update_cloud_task_run_model(parent_cloud_task_run) + # Voiceover regeneration for a large number of contents within a single # Cloud Task run (deferred request) significantly increases the workload and # may lead to timeout failures due to Gunicorn limitations of 60 seconds. @@ -1391,6 +1427,9 @@ def wrap_up_voiceover_regeneration_task( ) parent_cloud_task_run.latest_job_state = 'PERMANENTLY_FAILED' taskqueue_services.update_cloud_task_run_model(parent_cloud_task_run) + else: + parent_cloud_task_run.latest_job_state = 'SUCCEEDED' + taskqueue_services.update_cloud_task_run_model(parent_cloud_task_run) voiceover_regeneration_job_status.update_remaining_content_status_as_succeeded() voiceover_cloud_task_services.save_voiceover_regeneration_job( @@ -1522,11 +1561,25 @@ def regenerate_voiceovers_on_exploration_update( logging.info( 'Voiceover regeneration logs: %s' % language_code_to_contents_mapping ) + + try: + exploration_summary = exp_fetchers.get_exploration_summary_by_id( + exploration_id + ) + exploration_title = exploration_summary.title + + additional_contextual_information = { + 'exploration_title': exploration_title + } + except Exception as _: + additional_contextual_information = {} + regenerate_voiceovers_for_given_contents( exploration_id, exploration_version, language_code_to_contents_mapping, task_run_id, + additional_contextual_information, ) @@ -1551,6 +1604,7 @@ def regenerate_voiceovers_on_exploration_added_to_topic( 'Voiceover regeneration logs: Started regenerating voiceovers for ' 'exploration with ID: %s when it is added to topic.' % exploration_id ) + # A dictionary where each key is a language code, and each value is a # content mapping dictionary. The content mapping dictionary contains # content IDs as keys and their corresponding HTML content as values. @@ -1561,6 +1615,24 @@ def regenerate_voiceovers_on_exploration_added_to_topic( exploration_version = exploration.version + try: + exp_opportuinity_summary_model = ( + opportunity_models.ExplorationOpportunitySummaryModel.get_by_id( + exploration_id + ) + ) + topic_id = exp_opportuinity_summary_model.topic_id + topic = topic_fetchers.get_topic_by_id(topic_id) + topic_name = topic.name + exploration_title = exploration.title + + additional_contextual_information = { + 'exploration_title': exploration_title, + 'topic_name': topic_name, + } + except Exception as _: + additional_contextual_information = {} + # Retrieve all English-language contents from the exploration. language_code_to_contents_mapping.update( extract_english_voiceover_texts_from_exploration(exploration) @@ -1588,6 +1660,7 @@ def regenerate_voiceovers_on_exploration_added_to_topic( exploration_version, language_code_to_contents_mapping, task_run_id, + additional_contextual_information, ) @@ -1621,6 +1694,7 @@ def regenerate_voiceovers_of_exploration_for_given_language_accent( language_accent_code, ) ) + # A dictionary where each key is a language code, and each value is a # content mapping dictionary. The content mapping dictionary contains # content IDs as keys and their corresponding HTML content as values. @@ -1640,6 +1714,19 @@ def regenerate_voiceovers_of_exploration_for_given_language_accent( exploration_version = exploration.version + try: + exploration_title = exploration.title + language_accent = get_language_accent_codes_to_descriptions().get( + language_accent_code, '' + ) + + additional_contextual_information = { + 'exploration_title': exploration_title, + 'language_accent': language_accent, + } + except Exception as _: + additional_contextual_information = {} + if language_code == constants.constants.DEFAULT_LANGUAGE_CODE: # Retrieve all English-language contents from the exploration. language_code_to_contents_mapping.update( @@ -1667,10 +1754,30 @@ def regenerate_voiceovers_of_exploration_for_given_language_accent( exploration_version, language_code_to_contents_mapping, cloud_task_run_id, + additional_contextual_information, specific_language_accent_code=language_accent_code, ) +def get_language_description_from_code(language_code: str) -> Optional[str]: + """Returns the language description for the given language code. + + Args: + language_code: str. The language code for which the description is to be + retrieved. + + Returns: + str|None. The language description corresponding to the given language + code, or None if not found. + """ + for language_map in constants.constants.SUPPORTED_CONTENT_LANGUAGES: + if language_map['code'] == language_code: + description = language_map.get('description') + if isinstance(description, str): + return description + return None + + def regenerate_voiceovers_after_accepting_suggestion( suggestion_id: str, task_run_id: str, @@ -1695,6 +1802,20 @@ def regenerate_voiceovers_after_accepting_suggestion( language_code_to_contents_mapping = { language_code: {content_id: translated_html_content} } + try: + exploration_summary = exp_fetchers.get_exploration_summary_by_id( + exploration_id + ) + exploration_title = exploration_summary.title + + additional_contextual_information = { + 'exploration_title': exploration_title, + 'language': get_language_description_from_code(language_code) + or language_code, + } + except Exception as _: + additional_contextual_information = {} + logging.info( 'Voiceover regeneration logs: %s' % language_code_to_contents_mapping ) @@ -1703,4 +1824,5 @@ def regenerate_voiceovers_after_accepting_suggestion( exploration_version, language_code_to_contents_mapping, task_run_id, + additional_contextual_information, ) diff --git a/core/domain/voiceover_services_test.py b/core/domain/voiceover_services_test.py index b219489302975..34e0238bbed89 100644 --- a/core/domain/voiceover_services_test.py +++ b/core/domain/voiceover_services_test.py @@ -21,15 +21,20 @@ import json import os import uuid +from types import SimpleNamespace -from core import feconf, schema_utils -from core.constants import constants -from core.domain import exp_domain, exp_services +from core import constants, feconf, schema_utils +from core.domain import exp_domain, exp_fetchers, exp_services from core.domain import platform_parameter_list as param_list from core.domain import ( state_domain, + story_domain, + story_services, suggestion_services, taskqueue_services, + topic_domain, + topic_fetchers, + topic_services, translation_domain, translation_fetchers, voiceover_domain, @@ -47,6 +52,7 @@ cloud_task_models, email_models, exp_models, + opportunity_models, translation_models, voiceover_models, ) @@ -54,6 +60,7 @@ ( cloud_task_models, exp_models, + opportunity_models, email_models, voiceover_models, translation_models, @@ -61,6 +68,7 @@ [ models.Names.CLOUD_TASK, models.Names.EXPLORATION, + models.Names.OPPORTUNITY, models.Names.EMAIL, models.Names.VOICEOVER, models.Names.TRANSLATION, @@ -324,7 +332,6 @@ def test_should_get_entity_voiceovers_by_exploration_and_language_code( 'exp_id_1', 'exploration', 1, 'en' ) ) - self.assertEqual(len(retrieved_entity_voiceovers), 2) def test_should_compute_entity_voiceovers_model_successfully(self) -> None: @@ -1029,7 +1036,13 @@ class VoiceoverRegenerationTests(test_utils.GenericTestBase): def setUp(self) -> None: super().setUp() self.signup(self.VIEWER_EMAIL, self.VIEWER_USERNAME) + self.signup(self.CURRICULUM_ADMIN_EMAIL, self.CURRICULUM_ADMIN_USERNAME) + self.set_curriculum_admins([self.CURRICULUM_ADMIN_USERNAME]) + self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME) self.committer_1_id = self.get_user_id_from_email(self.VIEWER_EMAIL) + self.admin_id = self.get_user_id_from_email(self.CURRICULUM_ADMIN_EMAIL) + + self.owner_id = self.get_user_id_from_email(self.OWNER_EMAIL) self.old_content_html = '

old content html

' self.new_content_html = '

new content html

' @@ -1098,7 +1111,7 @@ def test_should_regenerate_voiceover_for_curated_exploration_content_update( }, } ], - constants.ACTIVITY_STATUS_PRIVATE, + constants.constants.ACTIVITY_STATUS_PRIVATE, False, ) @@ -1179,7 +1192,7 @@ def test_should_regenerate_voiceover_for_translation_update(self) -> None: ).to_dict(), } ], - constants.ACTIVITY_STATUS_PRIVATE, + constants.constants.ACTIVITY_STATUS_PRIVATE, False, ) @@ -1290,7 +1303,7 @@ def test_should_not_regenerate_voiceover_for_non_supported_accents( }, } ], - constants.ACTIVITY_STATUS_PRIVATE, + constants.constants.ACTIVITY_STATUS_PRIVATE, False, ) @@ -1449,7 +1462,7 @@ def test_should_raise_error_while_regenerating_voiceover(self) -> None: }, } ], - constants.ACTIVITY_STATUS_PRIVATE, + constants.constants.ACTIVITY_STATUS_PRIVATE, False, ) @@ -1576,7 +1589,7 @@ def test_should_raise_exception_while_regenerating_voiceovers_in_batch( }, } ], - constants.ACTIVITY_STATUS_PRIVATE, + constants.constants.ACTIVITY_STATUS_PRIVATE, False, ) @@ -1650,13 +1663,12 @@ def _create_exploration_and_arabic_translation( exploration_id: str. The ID of the exploration to create. language_code: str. The language code for the translation. """ - self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME) owner_id = self.get_user_id_from_email(self.OWNER_EMAIL) exploration = exp_domain.Exploration.create_default_exploration( exploration_id, title='A Title', - category='A Category', + category=constants.constants.ALL_CATEGORIES[0], objective='An Objective', ) exploration.states['Introduction'].content.html = 'First Card!' @@ -1702,6 +1714,8 @@ def _create_exploration_and_arabic_translation( exp_services.save_new_exploration(owner_id, exploration) + self.publish_exploration(self.owner_id, exploration_id) + arabic_translation = 'المحتوى المترجم' translations_mapping: Dict[str, feconf.TranslatedContentDict] = { 'content_0': { @@ -1733,6 +1747,67 @@ def _create_exploration_and_arabic_translation( language_code, translations_mapping, ).put() + topic_id_1 = 'topic_id_1' + story_id_1 = 'story_id_1' + curated_exploration_1 = exploration_id + + topic_1 = topic_domain.Topic.create_default_topic( + topic_id_1, 'topic1', 'abbrev', 'description', 'fragm' + ) + topic_1.thumbnail_filename = 'thumbnail.svg' + topic_1.thumbnail_bg_color = '#C6DCDA' + topic_1.subtopics = [ + topic_domain.Subtopic( + 1, + 'Title', + ['skill_id_1'], + 'image.svg', + constants.constants.ALLOWED_THUMBNAIL_BG_COLORS['subtopic'][0], + 21131, + 'dummy-subtopic-url', + ) + ] + topic_1.next_subtopic_id = 2 + topic_1.skill_ids_for_diagnostic_test = ['skill_id_1'] + + topic_services.save_new_topic(owner_id, topic_1) + topic_services.publish_topic(topic_id_1, self.admin_id) + + story_1 = story_domain.Story.create_default_story( + story_id_1, + 'A story', + 'Description', + topic_id_1, + 'story-two', + ) + story_services.save_new_story(owner_id, story_1) + topic_services.add_canonical_story(owner_id, topic_id_1, story_id_1) + + topic_services.publish_story(topic_id_1, story_id_1, self.admin_id) + + story_services.update_story( + owner_id, + story_id_1, + [ + story_domain.StoryChange( + { + 'cmd': 'add_story_node', + 'node_id': 'node_1', + 'title': 'Node1', + } + ), + story_domain.StoryChange( + { + 'cmd': 'update_story_node_property', + 'property_name': 'exploration_id', + 'node_id': 'node_1', + 'old_value': None, + 'new_value': curated_exploration_1, + } + ), + ], + 'Changes.', + ) def test_should_regenerate_voiceover_for_arabic_language(self) -> None: language_accent_code = 'ar-AE' @@ -1841,6 +1916,316 @@ def test_should_raise_exception_when_language_accent_code_is_not_supported( ) ) + def test_should_return_none_when_language_description_has_non_string_value( + self, + ) -> None: + self.swap( + constants.constants, + 'SUPPORTED_CONTENT_LANGUAGES', + [{'code': 'en', 'description': 123}], + ) + + self.assertIsNone( + voiceover_services.get_language_description_from_code('error') + ) + + def test_should_use_empty_context_when_title_lookup_fails_for_language_accent_regeneration( + self, + ) -> None: + class ExploitationWithBrokenTitle: + """A mock exploration class that raises an exception when the + title is accessed. + """ + + version = 1 + + @property + def title(self) -> str: + """A mock exploration class method that raises an exception + when the title is accessed. + + Raises: + Exception. Always raises an exception to simulate a title lookup failure. + """ + raise Exception('title lookup failed') + + exploration = ExploitationWithBrokenTitle() + captured_additional_contextual_information = {} + + def mock_regenerate_voiceovers_for_given_contents( + _exploration_id: str, + _exploration_version: int, + _language_code_to_contents_mapping: Dict[str, Dict[str, str]], + _task_run_id: str, + additional_contextual_information: Dict[str, str], + specific_language_accent_code: str | None = None, + ) -> None: + _ = specific_language_accent_code + captured_additional_contextual_information.update( + additional_contextual_information + ) + + with self.swap( + exp_fetchers, + 'get_exploration_by_id', + lambda _exploration_id: exploration, + ): + with self.swap( + voiceover_services, + 'extract_english_voiceover_texts_from_exploration', + lambda _exploration: {}, + ): + with self.swap( + voiceover_services, + 'regenerate_voiceovers_for_given_contents', + mock_regenerate_voiceovers_for_given_contents, + ): + voiceover_services.regenerate_voiceovers_of_exploration_for_given_language_accent( + 'exp_id_1', 'en-US', 'task_run_id' + ) + + self.assertDictEqual(captured_additional_contextual_information, {}) + + def test_should_use_exploration_title_context_when_summary_lookup_succeeds( + self, + ) -> None: + exploration_id = 'exp_id_1' + exploration_version = 2 + captured_additional_contextual_information = {} + + commit_log_entry = exp_models.ExplorationCommitLogEntryModel.create( + exploration_id, + exploration_version, + self.committer_1_id, + 'msg', + 'create', + [ + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'property_name': exp_domain.STATE_PROPERTY_CONTENT, + 'state_name': 'State 1', + 'old_value': { + 'content_id': 'content_5', + 'html': self.old_content_html, + }, + 'new_value': { + 'content_id': 'content_5', + 'html': self.new_content_html, + }, + } + ], + constants.constants.ACTIVITY_STATUS_PRIVATE, + False, + ) + commit_log_entry.exploration_id = exploration_id + commit_log_entry.update_timestamps() + commit_log_entry.put() + + def mock_regenerate_voiceovers_for_given_contents( + _exploration_id: str, + _exploration_version: int, + _language_code_to_contents_mapping: Dict[str, Dict[str, str]], + _task_run_id: str, + additional_contextual_information: Dict[str, str], + specific_language_accent_code: str | None = None, + ) -> None: + _ = specific_language_accent_code + captured_additional_contextual_information.update( + additional_contextual_information + ) + + with self.swap( + exp_fetchers, + 'get_exploration_summary_by_id', + lambda _exploration_id: SimpleNamespace(title='Test Exploration'), + ): + with self.swap( + voiceover_services, + 'regenerate_voiceovers_for_given_contents', + mock_regenerate_voiceovers_for_given_contents, + ): + voiceover_services.regenerate_voiceovers_on_exploration_update( + exploration_id, exploration_version, 'task_run_id' + ) + + self.assertDictEqual( + captured_additional_contextual_information, + {'exploration_title': 'Test Exploration'}, + ) + + def test_should_use_topic_and_exploration_context_when_topic_lookup_succeeds( + self, + ) -> None: + captured_additional_contextual_information = {} + + def mock_regenerate_voiceovers_for_given_contents( + _exploration_id: str, + _exploration_version: int, + _language_code_to_contents_mapping: Dict[str, Dict[str, str]], + _task_run_id: str, + additional_contextual_information: Dict[str, str], + specific_language_accent_code: str | None = None, + ) -> None: + _ = specific_language_accent_code + captured_additional_contextual_information.update( + additional_contextual_information + ) + + with self.swap( + exp_fetchers, + 'get_exploration_by_id', + lambda _exploration_id: SimpleNamespace( + version=1, title='Curated Exploration' + ), + ): + with self.swap( + opportunity_models.ExplorationOpportunitySummaryModel, + 'get_by_id', + lambda _exploration_id: SimpleNamespace(topic_id='topic_1'), + ): + with self.swap( + topic_fetchers, + 'get_topic_by_id', + lambda _topic_id: SimpleNamespace(name='Sample Topic'), + ): + with self.swap( + voiceover_services, + 'extract_english_voiceover_texts_from_exploration', + lambda _exploration: {}, + ): + with self.swap( + translation_fetchers, + 'get_all_entity_translations_for_entity', + lambda *_args, **_kwargs: [], + ): + with self.swap( + voiceover_services, + 'extract_translated_voiceover_texts_from_entity_translations', + lambda _entity_translations: {}, + ): + with self.swap( + voiceover_services, + 'regenerate_voiceovers_for_given_contents', + mock_regenerate_voiceovers_for_given_contents, + ): + voiceover_services.regenerate_voiceovers_on_exploration_added_to_topic( + 'exp_id_1', 'task_run_id' + ) + + self.assertDictEqual( + captured_additional_contextual_information, + { + 'exploration_title': 'Curated Exploration', + 'topic_name': 'Sample Topic', + }, + ) + + def test_should_use_empty_context_when_topic_lookup_fails_for_curated_regeneration( + self, + ) -> None: + captured_additional_contextual_information = {} + + def mock_regenerate_voiceovers_for_given_contents( + _exploration_id: str, + _exploration_version: int, + _language_code_to_contents_mapping: Dict[str, Dict[str, str]], + _task_run_id: str, + additional_contextual_information: Dict[str, str], + specific_language_accent_code: str | None = None, + ) -> None: + _ = specific_language_accent_code + captured_additional_contextual_information.update( + additional_contextual_information + ) + + with self.swap( + exp_fetchers, + 'get_exploration_by_id', + lambda _exploration_id: SimpleNamespace( + version=1, title='Curated Exploration' + ), + ): + with self.swap( + opportunity_models.ExplorationOpportunitySummaryModel, + 'get_by_id', + lambda _exploration_id: (_ for _ in ()).throw( + Exception('topic lookup failed') + ), + ): + with self.swap( + voiceover_services, + 'extract_english_voiceover_texts_from_exploration', + lambda _exploration: {}, + ): + with self.swap( + translation_fetchers, + 'get_all_entity_translations_for_entity', + lambda *_args, **_kwargs: [], + ): + with self.swap( + voiceover_services, + 'extract_translated_voiceover_texts_from_entity_translations', + lambda _entity_translations: {}, + ): + with self.swap( + voiceover_services, + 'regenerate_voiceovers_for_given_contents', + mock_regenerate_voiceovers_for_given_contents, + ): + voiceover_services.regenerate_voiceovers_on_exploration_added_to_topic( + 'exp_id_1', 'task_run_id' + ) + + self.assertDictEqual(captured_additional_contextual_information, {}) + + def test_should_use_empty_context_when_exploration_summary_lookup_fails_after_accepting_suggestion( + self, + ) -> None: + captured_additional_contextual_information = {} + + suggestion = SimpleNamespace( + change_cmd=SimpleNamespace( + translation_html='

translated

', content_id='content_0' + ), + language_code='en', + target_id='exp_id_1', + target_version_at_submission=1, + ) + + def mock_regenerate_voiceovers_for_given_contents( + _exploration_id: str, + _exploration_version: int, + _language_code_to_contents_mapping: Dict[str, Dict[str, str]], + _task_run_id: str, + additional_contextual_information: Dict[str, str], + ) -> None: + captured_additional_contextual_information.update( + additional_contextual_information + ) + + with self.swap( + suggestion_services, + 'get_suggestion_by_id', + lambda _suggestion_id: suggestion, + ): + with self.swap( + exp_fetchers, + 'get_exploration_summary_by_id', + lambda _exploration_id: (_ for _ in ()).throw( + Exception('summary lookup failed') + ), + ): + with self.swap( + voiceover_services, + 'regenerate_voiceovers_for_given_contents', + mock_regenerate_voiceovers_for_given_contents, + ): + voiceover_services.regenerate_voiceovers_after_accepting_suggestion( + 'suggestion_id', 'task_run_id' + ) + + self.assertDictEqual(captured_additional_contextual_information, {}) + def test_should_regenerate_voiceover_for_english_language(self) -> None: language_accent_code = 'en-US' language_code = 'en' @@ -2063,13 +2448,12 @@ def test_should_generate_voiceover_for_translated_content(self) -> None: exploration_version = 1 language_accent_code = 'hi-IN' - self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME) - owner_id = self.get_user_id_from_email(self.OWNER_EMAIL) + owner_id = self.owner_id exploration = exp_domain.Exploration.create_default_exploration( exploration_id, title='A Title', - category='A Category', + category=constants.constants.ALL_CATEGORIES[0], objective='An Objective', ) exploration.states['Introduction'].content.html = 'First Card!' diff --git a/core/feature_flag_list.py b/core/feature_flag_list.py index 9c129309eb8fe..e0efc849251e7 100644 --- a/core/feature_flag_list.py +++ b/core/feature_flag_list.py @@ -152,10 +152,6 @@ class FeatureNames(enum.Enum): FeatureNames.CD_ALLOW_UNDOING_TRANSLATION_REVIEW, FeatureNames.SHOW_VOICEOVER_TAB_FOR_NON_CURATED_EXPLORATIONS, FeatureNames.NEW_LESSON_PLAYER, - FeatureNames.AUTOMATIC_VOICEOVER_REGENERATION_FROM_EXP, - FeatureNames.HIGHLIGHT_SENTENCES_DURING_AUTOMATIC_VOICEOVER_PLAYBACK, - FeatureNames.SHOW_REGENERATED_VOICEOVERS_TO_LEARNERS, - FeatureNames.ENABLE_BACKGROUND_VOICEOVER_SYNTHESIS, FeatureNames.ENABLE_FINANCIAL_LITERACY_CAMPAIGN_BANNER_TEST_MODE, FeatureNames.WEB_FEEDBACK_MODAL_ENABLED, FeatureNames.ENABLE_TRANSLATION_OPPORTUNITIES_WITH_NEW_OPP_MODELS, @@ -173,6 +169,10 @@ class FeatureNames(enum.Enum): FeatureNames.SHOW_REDESIGNED_LEARNER_DASHBOARD, FeatureNames.SHOW_RESTRUCTURED_STUDY_GUIDES, FeatureNames.ENABLE_FINANCIAL_LITERACY_CAMPAIGN_BANNER, + FeatureNames.AUTOMATIC_VOICEOVER_REGENERATION_FROM_EXP, + FeatureNames.HIGHLIGHT_SENTENCES_DURING_AUTOMATIC_VOICEOVER_PLAYBACK, + FeatureNames.SHOW_REGENERATED_VOICEOVERS_TO_LEARNERS, + FeatureNames.ENABLE_BACKGROUND_VOICEOVER_SYNTHESIS, ] # Names of features that should not be used anymore, e.g. features that are @@ -290,7 +290,7 @@ class FeatureNames(enum.Enum): ( 'The flag enables the automatic regeneration of voiceovers ' 'directly from the exploration editor page.', - feature_flag_domain.ServerMode.TEST, + feature_flag_domain.ServerMode.PROD, ) ), FeatureNames.SHOW_VOICEOVER_TAB_FOR_NON_CURATED_EXPLORATIONS.value: ( @@ -319,7 +319,7 @@ class FeatureNames(enum.Enum): ( 'This flag allows learners to see the regenerated voiceovers ' 'in the exploration player.', - feature_flag_domain.ServerMode.TEST, + feature_flag_domain.ServerMode.PROD, ) ), FeatureNames.HIGHLIGHT_SENTENCES_DURING_AUTOMATIC_VOICEOVER_PLAYBACK.value: ( @@ -327,14 +327,14 @@ class FeatureNames(enum.Enum): 'This flag enables the highlighting of sentences during the ' 'automatic voiceover playback in the exploration player and ' 'editor pages.', - feature_flag_domain.ServerMode.TEST, + feature_flag_domain.ServerMode.PROD, ) ), FeatureNames.ENABLE_BACKGROUND_VOICEOVER_SYNTHESIS.value: ( ( 'The flag enables the asynchronous voiceover synthesis for the ' 'curated exploration contents.', - feature_flag_domain.ServerMode.TEST, + feature_flag_domain.ServerMode.PROD, ) ), FeatureNames.ENABLE_READY_FOR_REVIEW_TEST.value: ( diff --git a/core/jobs/batch_jobs/exp_end_state_normalization_jobs.py b/core/jobs/batch_jobs/exp_end_state_normalization_jobs.py new file mode 100644 index 0000000000000..b037415e6de22 --- /dev/null +++ b/core/jobs/batch_jobs/exp_end_state_normalization_jobs.py @@ -0,0 +1,123 @@ +# coding: utf-8 +# +# 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. + +"""Jobs used for migrating the terminal state of the Exploration to have texts inside P tag.""" + +from __future__ import annotations + +from core.jobs import base_jobs +from core.jobs.io import ndb_io +from core.jobs.types import job_run_result +from core.platform import models + +import apache_beam as beam +from typing import Optional + +MYPY = False +if MYPY: # pragma: no cover + from mypy_imports import datastore_services, exp_models + +(base_models, exp_models) = models.Registry.import_models( + [models.Names.BASE_MODEL, models.Names.EXPLORATION] +) +datastore_services = models.Registry.import_datastore_services() + + +class ExplorationEndStateNormalizationJob(base_jobs.JobBase): + """A one-off job for normalizing the end state of explorations.""" + + DEFAULT_TERMINAL_STATE_CONTENT_WITHOUT_P_TAG = ( + 'Congratulations, you have finished!' + ) + DATASTORE_UPDATES_ALLOWED = True + + def update_end_state_content( + self, exploration_model: exp_models.ExplorationModel + ) -> Optional[exp_models.ExplorationModel]: + """Updates the end state content of the exploration to have texts inside P tag. + + Args: + exploration_model: exp_models.ExplorationModel. The exploration model to be updated. + + Returns: + Optional[exp_models.ExplorationModel]. The updated exploration + model if any changes were made, else None. + """ + is_exploration_updated = False + + with datastore_services.get_ndb_context(): + states = getattr(exploration_model, 'states', None) or {} + + for state in states.values(): + if ( + state.get('content', {}).get('html') + == self.DEFAULT_TERMINAL_STATE_CONTENT_WITHOUT_P_TAG + ): + state['content'][ + 'html' + ] = f'

{self.DEFAULT_TERMINAL_STATE_CONTENT_WITHOUT_P_TAG}

' + is_exploration_updated = True + + if is_exploration_updated: + return exploration_model + else: + return None + + def run(self) -> beam.PCollection[job_run_result.JobRunResult]: + """Returns a PCollection of job run results for ExplorationModels + that were updated after end state normalization. + + Returns: + beam.PCollection[job_run_result.JobRunResult]. A PCollection + containing job run results with the IDs of the + ExplorationModels that were updated or created. + """ + exploration_models = ( + self.pipeline + | 'Get exploration models' + >> ndb_io.GetModels(exp_models.ExplorationModel.get_all()) + ) + + updated_exploration_models = ( + exploration_models + | 'Update end state content' + >> beam.Map(self.update_end_state_content) + | 'Filter out None results' + >> beam.Filter(lambda exploration: exploration is not None) + ) + + if self.DATASTORE_UPDATES_ALLOWED: + _ = ( + updated_exploration_models + | 'Put models into datastore' >> ndb_io.PutModels() + ) + + return updated_exploration_models | 'Format results' >> beam.Map( + lambda exploration: job_run_result.JobRunResult( + stdout=( + f'Updated exploration with ID: {exploration.id} ' + 'after end state normalization.' + ) + ) + ) + + +class ExplorationEndStateNormalizationAuditJob( + ExplorationEndStateNormalizationJob +): + """A one-off job for auditing the end state of explorations.""" + + DATASTORE_UPDATES_ALLOWED = False diff --git a/core/jobs/batch_jobs/exp_end_state_normalization_jobs_test.py b/core/jobs/batch_jobs/exp_end_state_normalization_jobs_test.py new file mode 100644 index 0000000000000..ec44c6216ef3e --- /dev/null +++ b/core/jobs/batch_jobs/exp_end_state_normalization_jobs_test.py @@ -0,0 +1,130 @@ +# coding: utf-8 +# +# 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. + +"""Tests for exp_end_state_normalization_jobs.""" + +from __future__ import annotations + +from core.domain import exp_domain, exp_services +from core.jobs import job_test_utils +from core.jobs.batch_jobs import exp_end_state_normalization_jobs +from core.jobs.types import job_run_result +from core.platform import models + +MYPY = False +if MYPY: # pragma: no cover + from mypy_imports import datastore_services, exp_models + +(exp_models,) = models.Registry.import_models([models.Names.EXPLORATION]) +datastore_services = models.Registry.import_datastore_services() + + +class ExplorationEndStateNormalizationJobTests(job_test_utils.JobTestBase): + """Tests for ExplorationEndStateNormalizationJob.""" + + JOB_CLASS = ( + exp_end_state_normalization_jobs.ExplorationEndStateNormalizationJob + ) + + def test_job_skips_exploration_with_none_states(self) -> None: + """Tests that the job does not fail on exploration models with None states.""" + exploration = exp_domain.Exploration.create_default_exploration( + 'exp_id', title='Test Exploration', category='Test' + ) + exp_services.save_new_exploration('owner_id', exploration) + + exploration_model = exp_models.ExplorationModel.get_by_id('exp_id') + self.assertIsNotNone(exploration_model) + assert exploration_model is not None + + exploration_model.states = None + exploration_model.update_timestamps(update_last_updated_time=False) + datastore_services.put_multi([exploration_model]) + + self.assert_job_output_is_empty() + + def test_job_updates_content_without_paragraph_tag(self) -> None: + """Tests that the job reports updates when terminal content has no p-tag.""" + exploration = exp_domain.Exploration.create_default_exploration( + 'exp_id', title='Test Exploration', category='Test' + ) + exp_services.save_new_exploration('owner_id', exploration) + + exploration_model = exp_models.ExplorationModel.get_by_id('exp_id') + self.assertIsNotNone(exploration_model) + assert exploration_model is not None + + exploration_model.states['Introduction']['content'][ + 'html' + ] = self.JOB_CLASS.DEFAULT_TERMINAL_STATE_CONTENT_WITHOUT_P_TAG + exploration_model.update_timestamps(update_last_updated_time=False) + datastore_services.put_multi([exploration_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Updated exploration with ID: exp_id ' + 'after end state normalization.' + ) + ] + ) + + +class ExplorationEndStateNormalizationAuditJobTests(job_test_utils.JobTestBase): + """Tests for ExplorationEndStateNormalizationAuditJob.""" + + JOB_CLASS = ( + exp_end_state_normalization_jobs.ExplorationEndStateNormalizationAuditJob + ) + + def test_audit_job_reports_updates_without_saving_models(self) -> None: + """Tests that audit job reports matching explorations but does not update them.""" + exploration = exp_domain.Exploration.create_default_exploration( + 'exp_id', title='Test Exploration', category='Test' + ) + exp_services.save_new_exploration('owner_id', exploration) + + exploration_model = exp_models.ExplorationModel.get_by_id('exp_id') + self.assertIsNotNone(exploration_model) + assert exploration_model is not None + + exploration_model.states['Introduction']['content'][ + 'html' + ] = ( + exp_end_state_normalization_jobs.ExplorationEndStateNormalizationJob.DEFAULT_TERMINAL_STATE_CONTENT_WITHOUT_P_TAG + ) + exploration_model.update_timestamps(update_last_updated_time=False) + datastore_services.put_multi([exploration_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Updated exploration with ID: exp_id ' + 'after end state normalization.' + ) + ] + ) + + updated_exploration_model = exp_models.ExplorationModel.get_by_id( + 'exp_id' + ) + self.assertIsNotNone(updated_exploration_model) + assert updated_exploration_model is not None + + self.assertEqual( + updated_exploration_model.states['Introduction']['content']['html'], + exp_end_state_normalization_jobs.ExplorationEndStateNormalizationJob.DEFAULT_TERMINAL_STATE_CONTENT_WITHOUT_P_TAG, + ) diff --git a/core/jobs/batch_jobs/exp_search_indexing_jobs_test.py b/core/jobs/batch_jobs/exp_search_indexing_jobs_test.py index cbb204cf31b21..564d489e30ffe 100644 --- a/core/jobs/batch_jobs/exp_search_indexing_jobs_test.py +++ b/core/jobs/batch_jobs/exp_search_indexing_jobs_test.py @@ -72,12 +72,15 @@ def test_indexes_non_deleted_model(self) -> None: [ { 'id': 'abcd', - 'language_code': 'lang', + 'language_code': ['lang'], 'title': 'title', 'category': 'category', 'tags': [], 'objective': 'objective', 'rank': 20, + 'translated_titles': [], + 'translated_objectives': [], + 'translated_tags': [], } ], search_services.SEARCH_INDEX_EXPLORATIONS, @@ -115,12 +118,15 @@ def test_indexes_non_deleted_models(self) -> None: [ { 'id': 'abcd%s' % i, - 'language_code': 'lang', + 'language_code': ['lang'], 'title': 'title', 'category': 'category', 'tags': [], 'objective': 'objective', 'rank': 20, + 'translated_titles': [], + 'translated_objectives': [], + 'translated_tags': [], } ], search_services.SEARCH_INDEX_EXPLORATIONS, @@ -170,12 +176,15 @@ def add_docs_to_index_mock( [ { 'id': 'abcd', - 'language_code': 'lang', + 'language_code': ['lang'], 'title': 'title', 'category': 'category', 'tags': [], 'objective': 'objective', 'rank': 20, + 'translated_titles': [], + 'translated_objectives': [], + 'translated_tags': [], } ], search_services.SEARCH_INDEX_EXPLORATIONS, diff --git a/core/jobs/registry.py b/core/jobs/registry.py index 64ab5710887be..0182f596f3f9f 100644 --- a/core/jobs/registry.py +++ b/core/jobs/registry.py @@ -56,6 +56,7 @@ delete_duplicate_content_ids_jobs, dummy_jobs, email_deletion_jobs, + exp_end_state_normalization_jobs, exp_migration_jobs, exp_search_indexing_jobs, exploration_voiceover_sync_jobs, diff --git a/core/platform/search/elastic_search_services.py b/core/platform/search/elastic_search_services.py index 6ddd323cda6a5..bf02c0699005a 100644 --- a/core/platform/search/elastic_search_services.py +++ b/core/platform/search/elastic_search_services.py @@ -328,7 +328,14 @@ def search( 'multi_match': { 'query': query_string, 'type': 'bool_prefix', - 'fields': ['title^3', 'objective^2'], + 'fields': [ + 'title^3', + 'objective^2', + 'tags', + 'translated_titles^3', + 'translated_objectives^2', + 'translated_tags', + ], } } ] diff --git a/core/platform/search/elastic_search_services_test.py b/core/platform/search/elastic_search_services_test.py index 410021445bb6d..6956f6abf1c54 100644 --- a/core/platform/search/elastic_search_services_test.py +++ b/core/platform/search/elastic_search_services_test.py @@ -220,7 +220,14 @@ def mock_search( 'multi_match': { 'query': 'query', 'type': 'bool_prefix', - 'fields': ['title^3', 'objective^2'], + 'fields': [ + 'title^3', + 'objective^2', + 'tags', + 'translated_titles^3', + 'translated_objectives^2', + 'translated_tags', + ], } } ], diff --git a/core/storage/cloud_task/gae_models.py b/core/storage/cloud_task/gae_models.py index c204ee257a15d..366ec1ecc8a7c 100644 --- a/core/storage/cloud_task/gae_models.py +++ b/core/storage/cloud_task/gae_models.py @@ -140,6 +140,13 @@ class CloudTaskRunModel(base_models.BaseModel): required=True, indexed=True, default=0 ) + # This property stores additional contextual information related to the cloud + # task run—such as exploration title, topic name, or language accent. It is + # not used for querying and is intended solely for display purposes in the UI. + additional_contextual_information = datastore_services.JsonProperty( + required=False, indexed=False, default={} + ) + @staticmethod def get_deletion_policy() -> base_models.DELETION_POLICY: """Model doesn't contain any data directly corresponding to a user. @@ -176,6 +183,9 @@ def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]: base_models.EXPORT_POLICY.NOT_APPLICABLE ), 'current_retry_attempt': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'additional_contextual_information': ( + base_models.EXPORT_POLICY.NOT_APPLICABLE + ), }, ) diff --git a/core/storage/cloud_task/gae_models_test.py b/core/storage/cloud_task/gae_models_test.py index ca33351550704..55c21e406e875 100644 --- a/core/storage/cloud_task/gae_models_test.py +++ b/core/storage/cloud_task/gae_models_test.py @@ -49,6 +49,9 @@ def test_get_export_policy_not_applicable(self) -> None: 'exception_messages_for_failed_runs': ( base_models.EXPORT_POLICY.NOT_APPLICABLE ), + 'additional_contextual_information': ( + base_models.EXPORT_POLICY.NOT_APPLICABLE + ), 'latest_job_state': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'function_id': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'created_on': base_models.EXPORT_POLICY.NOT_APPLICABLE, diff --git a/core/templates/components/button-directives/primary-button.component.css b/core/templates/components/button-directives/primary-button.component.css index 975ce3ea93e01..399b59eaf96de 100644 --- a/core/templates/components/button-directives/primary-button.component.css +++ b/core/templates/components/button-directives/primary-button.component.css @@ -15,6 +15,7 @@ font-family: "Roboto", Arial, sans-serif; font-weight: 500; letter-spacing: 0.7px; + margin-left: 40px; } .primary-button:hover, diff --git a/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.css b/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.css index aebd23fd046bd..ffb8410babe8d 100644 --- a/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.css +++ b/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.css @@ -1,5 +1,16 @@ * { box-sizing: border-box; } +.assessment-interrupt-overlay { + align-items: center; + background: rgba(0, 0, 0, 0.5); + display: flex; + inset: 0; + justify-content: center; + padding: 16px; + position: fixed; + z-index: 1000; +} + .assessment-interrupt-card { background: #fff; border: 1px solid rgba(0, 0, 0, 0.12); diff --git a/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.html b/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.html index e8ba66e128752..45d4af544bef2 100644 --- a/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.html +++ b/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.html @@ -1,32 +1,34 @@ - - - diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.ts b/core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.ts index 0284966220d56..14b442801ef2c 100644 --- a/core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.ts +++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.ts @@ -21,6 +21,7 @@ import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; import {FocusManagerService} from 'services/stateful/focus-manager.service'; import {PlayerPositionService} from '../../services/player-position.service'; +import './flag-exploration-modal.component.css'; export interface FlagExplorationModalResult { report_type: boolean; @@ -31,6 +32,7 @@ export interface FlagExplorationModalResult { @Component({ selector: 'oppia-flag-exploration-modal', templateUrl: './flag-exploration-modal.component.html', + styleUrls: ['./flag-exploration-modal.component.css'], }) export class FlagExplorationModalComponent extends ConfirmOrCancelModal { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.css b/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.css new file mode 100644 index 0000000000000..ec2ad85473d35 --- /dev/null +++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.css @@ -0,0 +1,12 @@ +.oppia-learner-refresher-exploration-confirmation-body { + word-wrap: break-word; +} + +.oppia-learner-refresher-exploration-confirmation-title { + color: #00786d; + font-family: 'Capriola', Roboto, arial, sans-serif; + font-size: 1.25em; + font-weight: bold; + padding-top: 20px; + text-align: center; +} diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.html b/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.html index f5c0575c6dede..ada4203f790f6 100644 --- a/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.html +++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.html @@ -18,18 +18,3 @@ - - diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.ts b/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.ts index 73959fe6826a1..a452486504591 100644 --- a/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.ts +++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.ts @@ -23,10 +23,12 @@ import {UrlInterpolationService} from 'domain/utilities/url-interpolation.servic import {UrlService} from 'services/contextual/url.service'; import {WindowRef} from 'services/contextual/window-ref.service'; import {PageContextService} from 'services/page-context.service'; +import './refresher-exploration-confirmation-modal.component.css'; @Component({ selector: 'oppia-refresher-confirmation-modal', templateUrl: './refresher-exploration-confirmation-modal.component.html', + styleUrls: ['./refresher-exploration-confirmation-modal.component.css'], }) export class RefresherExplorationConfirmationModal extends ConfirmOrCancelModal { confirmRedirectEventEmitter: EventEmitter = new EventEmitter(); diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.css b/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.css new file mode 100644 index 0000000000000..ed99413f703d3 --- /dev/null +++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.css @@ -0,0 +1,3 @@ +.modal-body p { + line-height: 1.846; +} diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.html b/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.html index 83eabb3a87e03..38dac65263eeb 100644 --- a/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.html +++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.html @@ -17,9 +17,3 @@

- - diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.ts b/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.ts index e9086f41b99e2..ccd532ad8e5e2 100644 --- a/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.ts +++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.ts @@ -20,6 +20,7 @@ import {Component, Input} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {WindowRef} from 'services/contextual/window-ref.service'; +import './switch-content-language-refresh-required-modal.component.css'; export const INITIAL_CONTENT_LANGUAGE_CODE_URL_PARAM = 'initialContentLanguageCode'; @@ -28,7 +29,7 @@ export const INITIAL_CONTENT_LANGUAGE_CODE_URL_PARAM = selector: 'switch-content-language-refresh-required-modal', templateUrl: './switch-content-language-refresh-required-modal.component.html', - styleUrls: [], + styleUrls: ['./switch-content-language-refresh-required-modal.component.css'], }) export class SwitchContentLanguageRefreshRequiredModalComponent { @Input() languageCode!: string; diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.css b/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.css index cb3dac223788e..4da69306b6213 100644 --- a/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.css +++ b/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.css @@ -27,6 +27,15 @@ padding-bottom: 10px; } +.oppia-lesson-info-card .oppia-story-title { + color: #3b3b3b; + font-family: "Capriola", "Roboto", Arial, sans-serif; + font-size: 13px; + margin-bottom: -10px; + padding: 20px 20px 0; + text-align: left; +} + .oppia-info-card-exploration-contributors-profile img { border: 3px solid #fff; height: 22px; diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.spec.ts b/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.spec.ts index 134022c64cd53..aa9e6694ecc98 100644 --- a/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.spec.ts +++ b/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.spec.ts @@ -29,7 +29,10 @@ import { import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {TranslateService} from '@ngx-translate/core'; import {MockTranslateService} from '../../../../components/forms/schema-based-editors/integration-tests/schema-based-editors.integration.spec'; -import {ExplorationRatings} from '../../../../domain/summary/learner-exploration-summary.model'; +import { + ExplorationRatings, + TranslatableExplorationMetadataField, +} from '../../../../domain/summary/learner-exploration-summary.model'; import {UrlService} from '../../../../services/contextual/url.service'; import {UserService} from '../../../../services/user.service'; import {WindowRef} from '../../../../services/contextual/window-ref.service'; @@ -300,6 +303,15 @@ describe('Lesson Information card modal component', () => { expect(hackyExpDescTranslationIsDisplayed).toBe(false); }); + it('should not display hacky translation when metadata field is translated by backend', () => { + componentInstance.expInfo.translated_metadata_fields = [ + TranslatableExplorationMetadataField.TITLE, + TranslatableExplorationMetadataField.OBJECTIVE, + ]; + expect(componentInstance.isHackyExpTitleTranslationDisplayed()).toBe(false); + expect(componentInstance.isHackyExpDescTranslationDisplayed()).toBe(false); + }); + it("should determine if exploration isn't private upon initialization", () => { componentInstance.expInfo.status = 'public'; diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.ts b/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.ts index 1467281c32fba..7a60de5a9a259 100644 --- a/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.ts +++ b/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.ts @@ -17,11 +17,14 @@ */ import {Clipboard} from '@angular/cdk/clipboard'; -import {Component, ViewEncapsulation} from '@angular/core'; +import {Component} 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 {StateCard} from 'domain/state_card/state-card.model'; -import {LearnerExplorationSummaryBackendDict} from 'domain/summary/learner-exploration-summary.model'; +import { + LearnerExplorationSummaryBackendDict, + TranslatableExplorationMetadataField, +} from 'domain/summary/learner-exploration-summary.model'; import {UrlService} from 'services/contextual/url.service'; import {UserService} from 'services/user.service'; import {WindowRef} from 'services/contextual/window-ref.service'; @@ -55,7 +58,6 @@ const EXPLORATION_STATUS_PRIVATE = 'private'; selector: 'oppia-lesson-information-card-modal', templateUrl: './lesson-information-card-modal.component.html', styleUrls: ['./lesson-information-card-modal.component.css'], - encapsulation: ViewEncapsulation.None, }) export class LessonInformationCardModalComponent extends ConfirmOrCancelModal { // These properties below are initialized using Angular lifecycle hooks @@ -237,19 +239,27 @@ export class LessonInformationCardModalComponent extends ConfirmOrCancelModal { return this.urlInterpolationService.getStaticImageUrl(imageUrl); } + private isMetadataFieldTranslatedByBackend( + field: TranslatableExplorationMetadataField + ): boolean { + return (this.expInfo.translated_metadata_fields || []).includes(field); + } + isHackyExpTitleTranslationDisplayed(): boolean { - return ( - this.i18nLanguageCodeService.isHackyTranslationAvailable( - this.expTitleTranslationKey - ) && !this.i18nLanguageCodeService.isCurrentLanguageEnglish() + return this.i18nLanguageCodeService.isHackyTranslationDisplayed( + this.expTitleTranslationKey, + this.isMetadataFieldTranslatedByBackend( + TranslatableExplorationMetadataField.TITLE + ) ); } isHackyExpDescTranslationDisplayed(): boolean { - return ( - this.i18nLanguageCodeService.isHackyTranslationAvailable( - this.expDescTranslationKey - ) && !this.i18nLanguageCodeService.isCurrentLanguageEnglish() + return this.i18nLanguageCodeService.isHackyTranslationDisplayed( + this.expDescTranslationKey, + this.isMetadataFieldTranslatedByBackend( + TranslatableExplorationMetadataField.OBJECTIVE + ) ); } diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.css b/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.css index ea47466e80b24..2a05f42192192 100644 --- a/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.css +++ b/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.css @@ -130,7 +130,7 @@ width: 240px; } -oppia-progress-reminder-modal .restart-button:focus, -oppia-progress-reminder-modal .resume-button:focus { +.restart-button:focus, +.resume-button:focus { box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.5); } diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.ts b/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.ts index ad1a925ef9c3f..8b53764743af3 100644 --- a/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.ts +++ b/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.ts @@ -16,7 +16,7 @@ * @fileoverview Component for the progress reminder modal. */ -import {Component, ViewEncapsulation} from '@angular/core'; +import {Component} 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 {I18nLanguageCodeService} from 'services/i18n-language-code.service'; @@ -29,7 +29,6 @@ const CHECKPOINT_STATUS_IN_PROGRESS = 'in-progress'; selector: 'oppia-progress-reminder-modal', templateUrl: './progress-reminder-modal.component.html', styleUrls: ['./progress-reminder-modal.component.css'], - encapsulation: ViewEncapsulation.None, }) export class ProgressReminderModalComponent extends ConfirmOrCancelModal { // These properties below are initialized using Angular lifecycle hooks, diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/card-navigation-control.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/card-navigation-control.component.css index 3e46b6b1ce4fb..4ef8af6cdf483 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/card-navigation-control.component.css +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/card-navigation-control.component.css @@ -20,6 +20,11 @@ oppia-progress-tracker { text-transform: uppercase; } +.oppia-learner-view-card-content .oppia-learner-confirm-button { + margin-bottom: 10px; + margin-left: 10px; +} + .card-navigation-control .continue-button { margin-bottom: 8px; margin-left: 8px; diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/conversation-display.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/conversation-display.component.css index 4c0c96c12f024..de0fd3f429e63 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/conversation-display.component.css +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/conversation-display.component.css @@ -4,20 +4,15 @@ https://rtlcss.com/learn/usage-guide/control-directives . */ -oppia-conversation-display .oppia-learner-view-card-content .oppia-learner-confirm-button { - margin-bottom: 10px; - margin-left: 10px; -} - -oppia-conversation-display .conversation-skin-oppia-completed-state-card, -oppia-conversation-display .oppia-learner-view-card-top-section { +.conversation-skin-oppia-completed-state-card, +.oppia-learner-view-card-top-section { background-color: #fff; border-radius: 0 16px 16px; padding: 20px; position: relative; } -oppia-conversation-display .oppia-learner-view-card { +.oppia-learner-view-card { border-radius: 2px; margin-left: 40px ; max-width: 100vw; @@ -27,42 +22,42 @@ oppia-conversation-display .oppia-learner-view-card { text-align: left; } -oppia-conversation-display .oppia-learner-view-card-top-content { +.oppia-learner-view-card-top-content { border-top-left-radius: 0; padding: 12px; width: 100%; } -oppia-conversation-display .oppia-content-language-selector-container { +.oppia-content-language-selector-container { float: right; margin-bottom: 12px; margin-right: 0; } -oppia-conversation-display .oppia-content-language-selector-icon { +.oppia-content-language-selector-icon { color: rgb(75, 74, 74); font-size: 25px; margin-right: 4px; } -oppia-conversation-display .oppia-content-language-selector { +.oppia-content-language-selector { border-radius: 9px; font-size: 15px; margin-left: 5px; padding-left: 3px; } -oppia-conversation-display .oppia-exploration-language { +.oppia-exploration-language { padding: 0 5px 0 5px; } -oppia-conversation-display .oppia-learner-view-card-content { +.oppia-learner-view-card-content { word-wrap: break-word; } -oppia-conversation-display .conversation-skin-instruction-disabled { +.conversation-skin-instruction-disabled { color: gray; } -oppia-conversation-display .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar, -oppia-conversation-display .oppia-learner-view-card-top-section .conversation-skin-oppia-avatar { +.conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar, +.oppia-learner-view-card-top-section .conversation-skin-oppia-avatar { border: 2px solid #c55f45; border-radius: 50%; height: 100px; @@ -71,7 +66,7 @@ oppia-conversation-display .oppia-learner-view-card-top-section .conversation-sk width: 100px; } -oppia-conversation-display .left-side-curve { +.left-side-curve { height: 49px; left: -37px; position: absolute; @@ -79,17 +74,17 @@ oppia-conversation-display .left-side-curve { width: 39px; } -oppia-conversation-display .oppia-learner-view-card-top-content p:not(:last-child) { +.oppia-learner-view-card-top-content p:not(:last-child) { line-height: 1.5; margin-bottom: 18px; } /* This should match the value defined in AUDIO_HIGHLIGHT_CSS_CLASS. */ -oppia-conversation-display .conversation-skin-audio-highlight { +.conversation-skin-audio-highlight { border: 3px solid #f3d140; } -oppia-conversation-display .conversation-skin-future-tutor-card { +.conversation-skin-future-tutor-card { background: #fff; border-radius: 2px; margin-left: 87px; @@ -97,61 +92,61 @@ oppia-conversation-display .conversation-skin-future-tutor-card { text-align: left; } -oppia-conversation-display .conversation-skin-tutor-card-middle-section .conversation-skin-learner-answer-content { +.conversation-skin-tutor-card-middle-section .conversation-skin-learner-answer-content { background-color: #fff8e5; } -oppia-conversation-display .conversation-skin-tutor-card-middle-section .conversation-skin-oppia-feedback-content { +.conversation-skin-tutor-card-middle-section .conversation-skin-oppia-feedback-content { background-color: #fff; } -oppia-conversation-display .conversation-skin-tutor-card-middle-section .conversation-skin-oppia-feedback-content::before, -oppia-conversation-display .conversation-skin-tutor-card-middle-section .conversation-skin-learner-answer-content::before { +.conversation-skin-tutor-card-middle-section .conversation-skin-oppia-feedback-content::before, +.conversation-skin-tutor-card-middle-section .conversation-skin-learner-answer-content::before { display: none; } -oppia-conversation-display .check-mark-hidden { +.check-mark-hidden { display: none; } -oppia-conversation-display .check-mark-skipped { +.check-mark-skipped { opacity: 0; } -oppia-conversation-display .end-chapter-check-mark { +.end-chapter-check-mark { transition-duration: 0.5s; transition-property: opacity; } -oppia-conversation-display .card-content-hidden { +.card-content-hidden { opacity: 0; } -oppia-conversation-display .new-conversation-skin-responses-dropdown-container { +.new-conversation-skin-responses-dropdown-container { cursor: pointer; flex-shrink: 0; margin: 20px 0; } -oppia-conversation-display .response-history-container { +.response-history-container { align-items: center; display: flex; } -oppia-conversation-display .response-history-container hr { +.response-history-container hr { background-color: #000; border: none; height: 2px; width: 23%; } -oppia-conversation-display .conversation-skin-responses-dropdown-text { +.conversation-skin-responses-dropdown-text { color: #000; font-size: 16px; padding: 0 10px; } -oppia-conversation-display .conversation-skin-responses-dropdown-icon { +.conversation-skin-responses-dropdown-icon { font-size: 13px; margin-bottom: -2px; -moz-transition: all 0.2s; @@ -160,7 +155,7 @@ oppia-conversation-display .conversation-skin-responses-dropdown-icon { transition: all 0.2s; } -oppia-conversation-display .conversation-skin-responses-dropdown-icon-rotated { +.conversation-skin-responses-dropdown-icon-rotated { margin-bottom: -2px; -moz-transform: rotate(90deg); -o-transform: rotate(90deg); @@ -169,31 +164,31 @@ oppia-conversation-display .conversation-skin-responses-dropdown-icon-rotated { transition: transform 0.2s ease-in-out; } -oppia-conversation-display .conversation-skin-inline-interaction { +.conversation-skin-inline-interaction { border-radius: 8px; margin-left: 40px; padding: 10px; position: relative; } -oppia-conversation-display .conversation-skin-inline-interaction .form-control { +.conversation-skin-inline-interaction .form-control { border: none; outline: none; resize: none; } -oppia-conversation-display input.form-control:focus { +input.form-control:focus { border: none; box-shadow: none; outline: none; } -oppia-conversation-display .conversation-skin-inline-interaction .conversation-skin-user-avatar { +.conversation-skin-inline-interaction .conversation-skin-user-avatar { left: -20px; top: 14px; } -oppia-conversation-display .conversation-skin-milestone-message { +.conversation-skin-milestone-message { align-items: center; display: flex; justify-content: center; @@ -201,11 +196,11 @@ oppia-conversation-display .conversation-skin-milestone-message { padding: 0 20px; } -oppia-conversation-display .conversation-skin-milestone-message.milestone-progress-message { +.conversation-skin-milestone-message.milestone-progress-message { flex-direction: column; } -oppia-conversation-display .conversation-skin-milestone-message .milestone-message-star-container { +.conversation-skin-milestone-message .milestone-message-star-container { align-items: center; display: flex; flex-direction: row; @@ -215,31 +210,31 @@ oppia-conversation-display .conversation-skin-milestone-message .milestone-messa width: 72px; } -oppia-conversation-display .conversation-skin-milestone-message .milestone-message-star-container .star-icon { +.conversation-skin-milestone-message .milestone-message-star-container .star-icon { position: absolute; transform: scale(1.5); } -oppia-conversation-display .conversation-skin-milestone-message .completed-chapter-count { +.conversation-skin-milestone-message .completed-chapter-count { font-weight: 500; margin-top: 9px; position: relative; } -oppia-conversation-display .conversation-skin-milestone-message .chapters-left-count { +.conversation-skin-milestone-message .chapters-left-count { font-weight: 500; margin-top: 9px; position: relative; } -oppia-conversation-display .conversation-skin-milestone-message .milestone-message-text { +.conversation-skin-milestone-message .milestone-message-text { font-size: 1.1em; font-weight: 800; margin-top: 9px; text-align: center; } -oppia-conversation-display .conversation-skin-milestone-message .milestone-message-progress-bar-container { +.conversation-skin-milestone-message .milestone-message-progress-bar-container { align-items: center; display: flex; justify-content: center; @@ -247,75 +242,75 @@ oppia-conversation-display .conversation-skin-milestone-message .milestone-messa width: 100%; } -oppia-conversation-display .milestone-message-progress-bar-container .milestone-progress-bar-outer { +.milestone-message-progress-bar-container .milestone-progress-bar-outer { border: 2px solid #00645c; height: 20px; margin-right: 30px; width: 300px; } -oppia-conversation-display .milestone-message-progress-bar-container .milestone-progress-bar-inner { +.milestone-message-progress-bar-container .milestone-progress-bar-inner { background: #a6dacf; height: 100%; } -oppia-conversation-display .milestone-message-progress-bar-container .milestone-message-star-container { +.milestone-message-progress-bar-container .milestone-message-star-container { margin-bottom: 9px; margin-left: -57px; } -oppia-conversation-display .oppia-conversation-skin-inline-interaction { +.oppia-conversation-skin-inline-interaction { opacity: 0.8; } -oppia-conversation-display .oppia-conversation-skin-instruction-container { +.oppia-conversation-skin-instruction-container { background-color: #fff; border-radius: 8px; padding: 6px 12px; } -oppia-conversation-display .oppia-no-interaction-text { +.oppia-no-interaction-text { color: #f00; } -oppia-conversation-display .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-feedback-content { +.conversation-skin-oppia-completed-state-card .conversation-skin-oppia-feedback-content { color: #888; font-style: italic; } -oppia-conversation-display .checkpoint-celebration-modal-container { +.checkpoint-celebration-modal-container { position: absolute; } -oppia-conversation-display .oppia-mcq-question-description-text { +.oppia-mcq-question-description-text { display: block; padding: 0 20px; } @media screen and (max-width: 959px) { - oppia-conversation-display .oppia-learner-view-card .instructions-button { + .oppia-learner-view-card .instructions-button { background-color: #0d48a1; color: #fff; padding: 6px 12px; white-space: normal; } - oppia-conversation-display .oppia-learner-view-card button.mat-button.mat-default-theme.instructions-button:focus, - oppia-conversation-display .oppia-learner-view-card button.mat-button.mat-default-theme.instructions-button:hover { + .oppia-learner-view-card button.mat-button.mat-default-theme.instructions-button:focus, + .oppia-learner-view-card button.mat-button.mat-default-theme.instructions-button:hover { background-color: #115fd4; } } @media (max-width: 480px) { - oppia-conversation-display .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar, - oppia-conversation-display .oppia-learner-view-card-top-section .conversation-skin-oppia-avatar { + .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar, + .oppia-learner-view-card-top-section .conversation-skin-oppia-avatar { height: 34px; left: -38px; top: 7px; width: 34px; } - oppia-conversation-display .left-side-curve { + .left-side-curve { height: 29px; left: -18px; position: absolute; @@ -324,26 +319,26 @@ oppia-conversation-display .oppia-mcq-question-description-text { } - oppia-conversation-display .oppia-learner-view-card { + .oppia-learner-view-card { margin-left: 40px; margin-top: 30px; padding-bottom: 30px; } - oppia-conversation-display .conversation-skin-responses-dropdown-text { + .conversation-skin-responses-dropdown-text { font-size: 14px; } } @media (min-width: 481px) and (max-width: 768px) { - oppia-conversation-display .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar, - oppia-conversation-display .oppia-learner-view-card-top-section .conversation-skin-oppia-avatar { + .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar, + .oppia-learner-view-card-top-section .conversation-skin-oppia-avatar { height: 64px; left: -68px; width: 64px; } - oppia-conversation-display .left-side-curve { + .left-side-curve { height: 39px; left: -28px; position: absolute; @@ -351,26 +346,26 @@ oppia-conversation-display .oppia-mcq-question-description-text { width: 29px; } - oppia-conversation-display .oppia-learner-view-card { + .oppia-learner-view-card { margin-left: 55px; margin-top: 30px; padding-bottom: 30px; } - oppia-conversation-display .oppia-conversation-display .oppia-learner-view-card-top-section { + .oppia-learner-view-card-top-section { width: 100%; } } @media (min-width: 769px) and (max-width: 1023px) { - oppia-conversation-display .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar, - oppia-conversation-display .oppia-learner-view-card-top-section .conversation-skin-oppia-avatar { + .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar, + .oppia-learner-view-card-top-section .conversation-skin-oppia-avatar { height: 64px; left: -68px; width: 64px; } - oppia-conversation-display .left-side-curve { + .left-side-curve { height: 39px; left: -28px; position: absolute; @@ -379,13 +374,13 @@ oppia-conversation-display .oppia-mcq-question-description-text { } } -oppia-conversation-display .conversation-skin-oppia-completed-state-card { +.conversation-skin-oppia-completed-state-card { margin-top: 35px; padding: 32px; } @media (min-width: 1024px) { - oppia-conversation-display .supplemental-history-response { + .supplemental-history-response { width: 75%; } } diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/display-new-hint-modal.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/display-new-hint-modal.component.css index 66ce70f143c08..eb8ee1c488bec 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/display-new-hint-modal.component.css +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/display-new-hint-modal.component.css @@ -4,7 +4,7 @@ https://rtlcss.com/learn/usage-guide/control-directives . */ -oppia-new-display-hint-modal .oppia-learner-hint-and-solution-modal-title { +.oppia-learner-hint-and-solution-modal-title { color: #c55f45; font-family: 'Capriola', 'Roboto', 'Arial', sans-serif; font-size: 1.25em; @@ -13,24 +13,24 @@ oppia-new-display-hint-modal .oppia-learner-hint-and-solution-modal-title { text-align: center; } -oppia-new-display-hint-modal .oppia-learner-hint-and-solution-modal-body { +.oppia-learner-hint-and-solution-modal-body { margin: 20px 0; word-wrap: break-word; } -oppia-new-display-hint-modal .modal-body { +.modal-body { max-height: calc(85vh - 125px); overflow-y: auto; } -oppia-new-display-hint-modal .oppia-learner-solution-container { +.oppia-learner-solution-container { align-items: center; display: flex; justify-content: space-between; padding: 16px 20px; } -oppia-new-display-hint-modal .oppia-learner-solution-container > p { +.oppia-learner-solution-container > p { color: #c55f45; left: 50%; margin-top: 10px; @@ -38,17 +38,17 @@ oppia-new-display-hint-modal .oppia-learner-solution-container > p { transform: translateX(-50%); } -oppia-new-display-hint-modal .mat-button.hint-display-modal-got-it-button { +.mat-button.hint-display-modal-got-it-button { background-color: #00645c !important; color: white; font-weight: 700; } -oppia-new-display-hint-modal .mat-button.hint-display-modal-got-it-button:focus { +.mat-button.hint-display-modal-got-it-button:focus { outline: none !important; } -oppia-new-display-hint-modal .hint-modal-control-btn { +.hint-modal-control-btn { border: 1px solid #00645c !important; color: #00645c; font-weight: 700; @@ -56,7 +56,7 @@ oppia-new-display-hint-modal .hint-modal-control-btn { } @media (max-width: 480px) { - oppia-new-display-hint-modal .oppia-learner-solution-container > p { + .oppia-learner-solution-container > p { margin-left: 10px; } } diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-input-response-pair.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-input-response-pair.component.css index c432cbfa78450..004fcaac4d260 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-input-response-pair.component.css +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-input-response-pair.component.css @@ -4,55 +4,52 @@ https://rtlcss.com/learn/usage-guide/control-directives . */ -oppia-new-input-response-pair .conversation-skin-learner-answer-container, -oppia-new-input-response-pair .conversation-skin-oppia-feedback-container { +.conversation-skin-learner-answer-container, +.conversation-skin-oppia-feedback-container { padding: 0 20px; position: relative; } -oppia-new-input-response-pair .conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar { +.conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar { height: 100px; left: -104px; top: 2px; width: 100px; } -oppia-new-input-response-pair .conversation-skin-oppia-feedback-container { +.conversation-skin-oppia-feedback-container { background-color: #fff; border-radius: 0 16px 16px; margin-top: 10px; width: 70%; } -oppia-new-input-response-pair .conversation-skin-learner-answer-content { +.conversation-skin-learner-answer-content { border-bottom-right-radius: 0; } -oppia-new-input-response-pair .conversation-skin-oppia-feedback-content, -oppia-new-input-response-pair .conversation-skin-learner-answer-content { +.conversation-skin-oppia-feedback-content { padding: 12px; word-wrap: break-word; } -oppia-new-input-response-pair .conversation-skin-oppia-feedback-content, -oppia-new-input-response-pair .conversation-skin-help-card-content { +.conversation-skin-oppia-feedback-content, +.conversation-skin-help-card-content { border-top-left-radius: 0; } -oppia-new-input-response-pair .conversation-skin-learner-answer-container { +.conversation-skin-learner-answer-container { background-color: #fff8e5; border-radius: 16px 0 16px 16px; margin-top: 10px; text-align: left; width: 70%; } - -oppia-new-input-response-pair .answer-wrapper { +.answer-wrapper { display: flex; justify-content: flex-end; } - -oppia-new-input-response-pair .answer-wrapper .right-side-curve { +.answer-wrapper .right-side-curve { height: 49px; position: absolute; right: -36px; @@ -60,51 +57,48 @@ oppia-new-input-response-pair .answer-wrapper .right-side-curve { transform: scaleX(-1); width: 39px; } - -oppia-new-input-response-pair .feedback-wrapper .left-side-curve { +.feedback-wrapper .left-side-curve { height: 49px; left: -37px; position: absolute; top: -9px; width: 39px; } - -oppia-new-input-response-pair .conversation-skin-learner-answer-container .conversation-skin-user-avatar { +.conversation-skin-learner-answer-container .conversation-skin-user-avatar { border: 2px solid #094142; height: 100px; right: -104px; top: 3px; width: 100px; } - -oppia-new-input-response-pair .oppia-popover { +.oppia-popover { cursor: pointer; display: inline-block; } -oppia-new-input-response-pair .oppia-answer-popup-container .oppia-answer-popup { + +.oppia-answer-popup-container .oppia-answer-popup { max-height: 300px; max-width: 300px; } -oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter { +.conversation-skin-oppia-feedback.ng-enter { opacity: 0; -webkit-transition: all .5s; transition: all .5s; } - -oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-enter-active { +.conversation-skin-oppia-feedback.ng-enter.ng-enter-active { opacity: 1; } @media (max-width: 480px) { - oppia-new-input-response-pair .conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar { + .conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar { height: 34px; left: -37px; top: 5px; width: 34px; } - oppia-new-input-response-pair .answer-wrapper .right-side-curve { + .answer-wrapper .right-side-curve { height: 29px; position: absolute; right: -17px; @@ -113,7 +107,7 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente width: 19px; } - oppia-new-input-response-pair .conversation-skin-learner-answer-container .conversation-skin-user-avatar { + .conversation-skin-learner-answer-container .conversation-skin-user-avatar { border: 2px solid #094142; height: 34px; right: -39px; @@ -121,7 +115,7 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente width: 34px; } - oppia-new-input-response-pair .feedback-wrapper .left-side-curve { + .feedback-wrapper .left-side-curve { height: 29px; left: -18px; position: absolute; @@ -131,14 +125,14 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente } @media (min-width: 481px) and (max-width: 768px) { - oppia-new-input-response-pair .conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar { + .conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar { height: 64px; left: -68px; top: 5px; width: 64px; } - oppia-new-input-response-pair .feedback-wrapper .left-side-curve { + .feedback-wrapper .left-side-curve { height: 39px; left: -27px; position: absolute; @@ -146,7 +140,7 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente width: 29px; } - oppia-new-input-response-pair .answer-wrapper .right-side-curve { + .answer-wrapper .right-side-curve { height: 39px; position: absolute; right: -28px; @@ -155,7 +149,7 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente width: 29px; } - oppia-new-input-response-pair .conversation-skin-learner-answer-container .conversation-skin-user-avatar { + .conversation-skin-learner-answer-container .conversation-skin-user-avatar { height: 64px; right: -69px; top: 3px; @@ -164,14 +158,14 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente } @media (min-width: 769px) and (max-width: 1023px) { - oppia-new-input-response-pair .conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar { + .conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar { height: 64px; left: -68px; top: 5px; width: 64px; } - oppia-new-input-response-pair .feedback-wrapper .left-side-curve { + .feedback-wrapper .left-side-curve { height: 39px; left: -27px; position: absolute; @@ -179,7 +173,7 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente width: 29px; } - oppia-new-input-response-pair .answer-wrapper .right-side-curve { + .answer-wrapper .right-side-curve { height: 39px; position: absolute; right: -28px; @@ -188,7 +182,7 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente width: 29px; } - oppia-new-input-response-pair .conversation-skin-learner-answer-container .conversation-skin-user-avatar { + .conversation-skin-learner-answer-container .conversation-skin-user-avatar { height: 64px; right: -69px; top: 3px; diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-ratings-and-recommendations.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-ratings-and-recommendations.component.css index 58fe3f252493c..125c213803299 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-ratings-and-recommendations.component.css +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-ratings-and-recommendations.component.css @@ -1,4 +1,4 @@ -oppia-new-ratings-and-recommendations .oppia-sign-up-button { +.oppia-sign-up-button { background-color: #00645c; border-radius: 15px; font-size: 1em; @@ -7,12 +7,12 @@ oppia-new-ratings-and-recommendations .oppia-sign-up-button { width: 154px; } -oppia-new-ratings-and-recommendations .conversation-skin-final-summary { +.conversation-skin-final-summary { margin-bottom: 25px; margin-top: 20px; } -oppia-new-ratings-and-recommendations .conversation-skin-final-ratings-header { +.conversation-skin-final-ratings-header { color: #333; font-family: 'Capriola', Arial, sans-serif; font-size: 20px; @@ -21,32 +21,32 @@ oppia-new-ratings-and-recommendations .conversation-skin-final-ratings-header { text-align: center; } -oppia-new-ratings-and-recommendations .conversation-skin-final-ratings { +.conversation-skin-final-ratings { margin: 0 auto 60px; text-align: center; } -oppia-new-ratings-and-recommendations .conversation-skin-final-ratings-display { +.conversation-skin-final-ratings-display { color: #064b46; letter-spacing: 10px; } -oppia-new-ratings-and-recommendations .conversation-skin-final-recommendations-header { +.conversation-skin-final-recommendations-header { color: #333; font-family: "Capriola", "Roboto", Arial, sans-serif; font-size: 20px; font-weight: 400; } -oppia-new-ratings-and-recommendations .conversation-skin-final-recommendations-header-container { +.conversation-skin-final-recommendations-header-container { text-align: center; } -oppia-new-ratings-and-recommendations .conversation-skin-recommended-activities-container { +.conversation-skin-recommended-activities-container { padding-top: 10px; } -oppia-new-ratings-and-recommendations .conversation-skin-back-to-collection-container { +.conversation-skin-back-to-collection-container { border: 1px solid #00645c; border-radius: 40px; margin: auto; @@ -55,11 +55,11 @@ oppia-new-ratings-and-recommendations .conversation-skin-back-to-collection-cont width: 35%; } -oppia-new-ratings-and-recommendations .conversation-skin-back-to-collection { +.conversation-skin-back-to-collection { color: #064b46; } -oppia-new-ratings-and-recommendations .conversation-skin-login-container { +.conversation-skin-login-container { background-color: #fff; border-bottom: 2px solid #bbb; border-top: 2px solid #bbb; @@ -71,40 +71,40 @@ oppia-new-ratings-and-recommendations .conversation-skin-login-container { width: 100%; } -oppia-new-ratings-and-recommendations .conversation-skin-login-container .conversation-skin-login-text { +.conversation-skin-login-container .conversation-skin-login-text { font-size: 0.8em; } -oppia-new-ratings-and-recommendations .conversation-skin-login-container .conversation-skin-congratulations-text { +.conversation-skin-login-container .conversation-skin-congratulations-text { font-size: 1.2em; font-weight: 500; } -oppia-new-ratings-and-recommendations .conversation-skin-login-container .conversation-skin-hide-sign-up-section-text { +.conversation-skin-login-container .conversation-skin-hide-sign-up-section-text { color: #00645c; cursor: pointer; text-decoration: underline; } -oppia-new-ratings-and-recommendations .conversation-skin-login-container .conversation-skin-login-button-text { +.conversation-skin-login-container .conversation-skin-login-button-text { color: #00645c; cursor: pointer; text-decoration: underline; } -oppia-new-ratings-and-recommendations .conversation-skin-login-container .conversation-skin-login-secondary-options { +.conversation-skin-login-container .conversation-skin-login-secondary-options { display: flex; justify-content: space-between; } -oppia-new-ratings-and-recommendations .conversation-skin-login-secondary-options button { +.conversation-skin-login-secondary-options button { background: none; border: none; cursor: default; font-size: 0.8em; } -oppia-new-ratings-and-recommendations .ratings-and-recommendations-exp-summary-tiles-container { +.ratings-and-recommendations-exp-summary-tiles-container { align-items: center; display: flex; flex-direction: column; @@ -114,7 +114,7 @@ oppia-new-ratings-and-recommendations .ratings-and-recommendations-exp-summary-t padding-top: 24px; } -oppia-new-ratings-and-recommendations .community-library-btn { +.community-library-btn { align-items: center; background-color: #e8f1f0; border: 1px solid #00645c; @@ -132,17 +132,17 @@ oppia-new-ratings-and-recommendations .community-library-btn { width: 325px; } -oppia-new-ratings-and-recommendations a:hover { +a:hover { text-decoration: none; } -oppia-new-ratings-and-recommendations .community-library-btn i { +.community-library-btn i { font-size: 24px; margin-right: 10px; } @media screen and (max-width: 959px) { - oppia-new-ratings-and-recommendations .conversation-skin-final-summary { + .conversation-skin-final-summary { margin: 0 auto; max-width: 650px; width: 100%; @@ -150,12 +150,12 @@ oppia-new-ratings-and-recommendations .community-library-btn i { } @media (max-width: 480px) { - oppia-new-ratings-and-recommendations .community-library-btn { + .community-library-btn { font-size: 13px; width: 240px; } - oppia-new-ratings-and-recommendations .community-library-btn i { + .community-library-btn i { font-size: 18px; } } diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.css new file mode 100644 index 0000000000000..ed99413f703d3 --- /dev/null +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.css @@ -0,0 +1,3 @@ +.modal-body p { + line-height: 1.846; +} diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.html b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.html index 6aa5489b5d636..2164f930377ff 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.html +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.html @@ -17,9 +17,3 @@

- - diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.ts b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.ts index a4cc9894e4054..e68f2627c8f56 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.ts @@ -18,11 +18,12 @@ import {Component} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import './take-break-modal.component.css'; @Component({ selector: 'take-break-modal', templateUrl: './take-break-modal.component.html', - styleUrls: [], + styleUrls: ['./take-break-modal.component.css'], }) export class TakeBreakModalComponent { constructor(private activeModal: NgbActiveModal) {} diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/checkpoint-celebration-footer.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/checkpoint-celebration-footer.component.css index 7bf539550e760..271e0aa6a937e 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/checkpoint-celebration-footer.component.css +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/checkpoint-celebration-footer.component.css @@ -1,4 +1,4 @@ -oppia-checkpoint-celebration-footer .inner-container { +.inner-container { -webkit-animation-delay: 0.5s; -ms-animation-delay: 0.5s; -webkit-animation-direction: normal; @@ -37,7 +37,7 @@ oppia-checkpoint-celebration-footer .inner-container { text-align: center; } -oppia-checkpoint-celebration-footer .inner-container-alignment { +.inner-container-alignment { bottom: 60px; left: 50%; padding-left: 30px; @@ -45,16 +45,16 @@ oppia-checkpoint-celebration-footer .inner-container-alignment { position: fixed; transform: translateX(-50%); } -oppia-checkpoint-celebration-footer .tick-symbol { +.tick-symbol { font-size: 42px; vertical-align: middle; } -oppia-checkpoint-celebration-footer .inner-content { +.inner-content { padding-left: 8px; } -oppia-checkpoint-celebration-footer .inner-container-fade-out { +.inner-container-fade-out { animation: fadeout 5s 1; } @@ -398,17 +398,17 @@ oppia-checkpoint-celebration-footer .dot18 { } @media(max-width: 959px) { - oppia-checkpoint-celebration-footer .inner-container { + .inner-container { max-width: 650px; } } @media(max-width: 480px) { - oppia-checkpoint-celebration-footer .inner-container { + .inner-container { min-width: 250px; } - oppia-checkpoint-celebration-footer .inner-content { + .inner-content { font-size: 16px; } diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/save-progress-modal.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/save-progress-modal.component.css index ae9cae664c513..7b80c6b06c65a 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/save-progress-modal.component.css +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/save-progress-modal.component.css @@ -4,11 +4,11 @@ https://rtlcss.com/learn/usage-guide/control-directives . */ -oppia-save-progress-modal .new-save-progress-menu { +.new-save-progress-menu { padding: 16px; } -oppia-save-progress-modal .new-save-progress-menu .checkpoint-status { +.new-save-progress-menu .checkpoint-status { align-items: center; display: flex; flex-direction: column; @@ -17,7 +17,7 @@ oppia-save-progress-modal .new-save-progress-menu .checkpoint-status { width: 100%; } -oppia-save-progress-modal .new-save-progress-menu h3 { +.new-save-progress-menu h3 { color: #00645c; font-family: capriola, sans-serif; font-size: 20px; @@ -25,7 +25,7 @@ oppia-save-progress-modal .new-save-progress-menu h3 { text-align: center; } -oppia-save-progress-modal .new-save-progress-menu .sign-in-box { +.new-save-progress-menu .sign-in-box { align-items: center; display: flex; flex-direction: column; @@ -34,16 +34,16 @@ oppia-save-progress-modal .new-save-progress-menu .sign-in-box { width: 100%; } -oppia-save-progress-modal.new-save-progress-menu .sign-in-box p { +.new-save-progress-menu .sign-in-box p { margin: 10px 20px; } -oppia-save-progress-modal .new-save-progress-menu .sign-in-box .sign-in-text a { +.new-save-progress-menu .sign-in-box .sign-in-text a { color: #00645c; text-decoration: underline; } -oppia-save-progress-modal .new-save-progress-menu .sign-in-box .create-account-btn { +.new-save-progress-menu .sign-in-box .create-account-btn { background-color: #00645c; color: #fff; cursor: pointer; @@ -55,7 +55,7 @@ oppia-save-progress-modal .new-save-progress-menu .sign-in-box .create-account-b width: 169px; } -oppia-save-progress-modal .new-save-progress-menu .uid-box { +.new-save-progress-menu .uid-box { align-items: center; display: flex; flex-direction: column; @@ -63,16 +63,16 @@ oppia-save-progress-modal .new-save-progress-menu .uid-box { width: 100%; } -oppia-save-progress-modal .new-save-progress-menu .uid-box .oppia-tooltip-container { +.new-save-progress-menu .uid-box .oppia-tooltip-container { width: 100%; } -oppia-save-progress-modal .new-save-progress-menu .uid-box .guide-text { +.new-save-progress-menu .uid-box .guide-text { margin-top: 32px; text-align: center; } -oppia-save-progress-modal .new-save-progress-menu .uid-box .copy-text { +.new-save-progress-menu .uid-box .copy-text { font-size: 14px; margin-bottom: 10px; padding-left: 20px; @@ -80,7 +80,7 @@ oppia-save-progress-modal .new-save-progress-menu .uid-box .copy-text { width: 100%; } -oppia-save-progress-modal .new-save-progress-menu .save-progress-close-button { +.new-save-progress-menu .save-progress-close-button { background: transparent; border: none; outline: none; diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.css index 62c546f7ac840..7c5e26e80eb20 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.css +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.css @@ -64,20 +64,7 @@ width: 100%; } -oppia-new-audio-bar .mat-slider-horizontal { - height: 0; - min-width: 120px; -} - -oppia-new-audio-bar .mat-slider { - padding: 0; -} - -oppia-new-audio-bar .mat-slider-wrapper { - position: static; -} - -oppia-new-audio-bar .mobile-mode { +.mobile-mode { display: none; } @@ -97,10 +84,10 @@ oppia-new-audio-bar .mobile-mode { width: 120px; } - oppia-new-audio-bar .desktop-mode { + .desktop-mode { display: none; } - oppia-new-audio-bar .mobile-mode { + .mobile-mode { display: block; margin-left: 30px; } diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.ts b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.ts index f9acb50a18cb1..f9adf35781414 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.ts @@ -21,7 +21,6 @@ import { Component, ElementRef, ViewChild, - ViewEncapsulation, } from '@angular/core'; import {StateEditorService} from 'components/state-editor/state-editor-properties-services/state-editor.service'; import {Voiceover} from 'domain/exploration/voiceover.model'; @@ -42,7 +41,6 @@ import {MobileMenuService} from 'pages/exploration-player-page/services/mobile-m selector: 'oppia-new-audio-bar', templateUrl: './new-audio-bar.component.html', styleUrls: ['./new-audio-bar.component.css'], - encapsulation: ViewEncapsulation.None, }) export class NewAudioBarComponent { @ViewChild('audioControls', {static: false}) audioControlsRef!: ElementRef; diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.css index 2c9a3647baed7..55b0e5fe9885f 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.css +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.css @@ -18,7 +18,7 @@ otherwise they will interfere with the iframed conversation skin directive. */ z-index: 16; } -oppia-new-conversation-skin .new-supplemental-card-parent-container { +.new-supplemental-card-parent-container { min-width: 50%; } @@ -57,10 +57,6 @@ oppia-new-conversation-skin .new-supplemental-card-parent-container { width: 100%; } -.conversation-skin-help-card-content { - padding: 12px; -} - .new-conversation-skin-tutor-card-container { margin: auto; max-width: 660px; @@ -97,7 +93,7 @@ oppia-new-conversation-skin .new-supplemental-card-parent-container { width: 40vw; } -oppia-new-conversation-skin .oppia-new-button-raised.mat-raised-raised { +.oppia-new-button-raised.mat-raised-raised { align-items: center; background-color: #0d48a1; display: flex; @@ -106,7 +102,7 @@ oppia-new-conversation-skin .oppia-new-button-raised.mat-raised-raised { width: 32px; } -oppia-new-conversation-skin .oppia-button-icon { +.oppia-button-icon { color: #fff; } diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.ts b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.ts index 2b338a4f04c27..6087591e7198d 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.ts @@ -18,12 +18,7 @@ import {Subscription} from 'rxjs'; import {StateCard} from 'domain/state_card/state-card.model'; -import { - ChangeDetectorRef, - Component, - Input, - ViewEncapsulation, -} from '@angular/core'; +import {ChangeDetectorRef, Component, Input} from '@angular/core'; import {WindowRef} from 'services/contextual/window-ref.service'; import {AlertsService} from 'services/alerts.service'; import {PageContextService} from 'services/page-context.service'; @@ -71,7 +66,6 @@ import {PreventPageUnloadEventService} from 'services/prevent-page-unload-event. selector: 'oppia-new-conversation-skin', templateUrl: './new-conversation-skin.component.html', styleUrls: ['./new-conversation-skin.component.css'], - encapsulation: ViewEncapsulation.None, }) export class NewConversationSkinComponent { // This throws "Type 'QuestionPlayerConfig' is not assignable to type 'QuestionPlayerConfigDict'". diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.css index fe45d1439828a..52b36ce2c081d 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.css +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.css @@ -4,7 +4,7 @@ https://rtlcss.com/learn/usage-guide/control-directives . */ -oppia-supplemental-card .conversation-skin-help-card { +.conversation-skin-help-card { background: #fff; border-radius: 2px; /* We move the help card lower so that it does not block @@ -23,23 +23,23 @@ oppia-supplemental-card .conversation-skin-help-card { z-index: 1001; } -oppia-supplemental-card .conversation-skin-help-card.ng-enter, -oppia-supplemental-card .conversation-skin-help-card.ng-leave-active { +.conversation-skin-help-card.ng-enter, +.conversation-skin-help-card.ng-leave-active { opacity: 0; transform: translateX(200px); } -oppia-supplemental-card .help-card-standard { +.help-card-standard { /* Use margin-bottom to make sure the help card doesn't get hidden behind the footer. */ margin-bottom: 80px; position: absolute; } -oppia-supplemental-card .conversation-skin-help-card .conversation-skin-oppia-avatar { +.conversation-skin-help-card .conversation-skin-oppia-avatar { left: -25px; } -oppia-supplemental-card .conversation-skin-close-help-card-button { +.conversation-skin-close-help-card-button { background: none; border: 0; color: #000; @@ -53,6 +53,11 @@ oppia-supplemental-card .conversation-skin-close-help-card-button { z-index: 200; } -oppia-supplemental-card .conversation-skin-supplemental-card { +.conversation-skin-supplemental-card { background-color: #fff; } + +.conversation-skin-help-card-content { + border-top-left-radius: 0; + padding: 12px; +} diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.ts b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.ts index 0fc1f4538f6e0..130088b4865cf 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.ts @@ -25,7 +25,6 @@ import { ViewChild, ChangeDetectorRef, SimpleChanges, - ViewEncapsulation, } from '@angular/core'; import {AppConstants} from 'app.constants'; import {StateCard} from 'domain/state_card/state-card.model'; @@ -44,7 +43,6 @@ import {ConversationFlowService} from 'pages/exploration-player-page/services/co selector: 'oppia-supplemental-card', templateUrl: './supplemental-card.component.html', styleUrls: ['./supplemental-card.component.css'], - encapsulation: ViewEncapsulation.None, }) export class SupplementalCardComponent implements OnInit, OnDestroy { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/header-components/player-header.component.ts b/core/templates/pages/exploration-player-page/new-lesson-player/header-components/player-header.component.ts index da1fc5afe35c8..7c4dc88cd0818 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/header-components/player-header.component.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/header-components/player-header.component.ts @@ -16,7 +16,7 @@ * @fileoverview Component for the new lesson player header */ -import {Component, ViewEncapsulation} from '@angular/core'; +import {Component} from '@angular/core'; import {ReadOnlyExplorationBackendApiService} from 'domain/exploration/read-only-exploration-backend-api.service'; import {ReadOnlyTopic} from 'domain/topic_viewer/read-only-topic.model'; import {TopicViewerBackendApiService} from 'domain/topic_viewer/topic-viewer-backend-api.service'; @@ -42,7 +42,6 @@ enum PageContextConstants { selector: 'oppia-player-header', templateUrl: './player-header.component.html', styleUrls: ['./player-header.component.css'], - encapsulation: ViewEncapsulation.None, }) export class PlayerHeaderComponent { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-page-root.component.ts b/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-page-root.component.ts index 0aec608b6e72e..bc116e2998804 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-page-root.component.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-page-root.component.ts @@ -16,11 +16,10 @@ * @fileoverview New lesson player page root component. */ -import {Component, ViewEncapsulation} from '@angular/core'; +import {Component} from '@angular/core'; @Component({ selector: 'oppia-new-lesson-player-page-root', templateUrl: './lesson-player-page-root.component.html', - encapsulation: ViewEncapsulation.None, }) export class NewLessonPlayerPageRootComponent {} diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-feedback-modal.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-feedback-modal.component.css index 86d5926d5f0b5..2b4e39385c329 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-feedback-modal.component.css +++ b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-feedback-modal.component.css @@ -1,19 +1,19 @@ -oppia-lesson-feedback-modal textarea { +textarea { margin-bottom: 10px; padding: 8px; resize: none; width: 100%; } -oppia-lesson-feedback-modal input[type="checkbox"] { +input[type="checkbox"] { margin-right: 5px; } -oppia-lesson-feedback-modal .feedback-submit-btn { +.feedback-submit-btn { background-color: #28a745; color: white; } -oppia-lesson-feedback-modal .feedback-submit-btn:disabled { +.feedback-submit-btn:disabled { background-color: #dfdee2; } diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/share-lesson-modal.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/share-lesson-modal.component.css index caca04e24b957..4bff550dee1bf 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/share-lesson-modal.component.css +++ b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/share-lesson-modal.component.css @@ -4,13 +4,13 @@ https://rtlcss.com/learn/usage-guide/control-directives . */ -oppia-share-lesson-modal .oppia-share-lesson-modal-header { +.oppia-share-lesson-modal-header { align-items: center; display: flex; justify-content: space-between; } -oppia-share-lesson-modal .oppia-share-lesson-modal-title { +.oppia-share-lesson-modal-title { color: #00645c; font-family: 'Capriola', 'Roboto', 'Arial', sans-serif; font-size: 1.25em; @@ -22,26 +22,26 @@ textarea::-webkit-scrollbar { display: none; } -oppia-share-lesson-modal .modal-body { +.modal-body { display: flex; flex-direction: column; padding: 24px; } -oppia-share-lesson-modal .oppia-share-lesson-modal-footer { +.oppia-share-lesson-modal-footer { align-items: center; display: flex; justify-content: space-between; padding: 16px 20px; } -oppia-share-lesson-modal .cancel-button { +.cancel-button { border: 1px solid #00645c; color: #00645c; font-weight: 700; } -oppia-share-lesson-modal .lesson-attribution-text { +.lesson-attribution-text { color: #00645c; cursor: pointer; font-size: 20px; @@ -49,13 +49,13 @@ oppia-share-lesson-modal .lesson-attribution-text { text-decoration: underline; } -oppia-share-lesson-modal .share-icon-container svg { +.share-icon-container svg { cursor: pointer; margin-bottom: 40px; margin-right: 12px; } -oppia-share-lesson-modal .share-modal-btn { +.share-modal-btn { border: 1px solid #205d86; border-radius: 4px; color: #205d86; @@ -66,13 +66,13 @@ oppia-share-lesson-modal .share-modal-btn { width: 210px; } -oppia-share-lesson-modal .share-modal-btn .button-content { +.share-modal-btn .button-content { align-items: center; display: flex; justify-content: space-between; } -oppia-share-lesson-modal .success-message { +.success-message { border-radius: 4px; color: white; height: 40px; @@ -80,7 +80,7 @@ oppia-share-lesson-modal .success-message { width: 200px; } -oppia-share-lesson-modal .success-message p { +.success-message p { align-items: center; background-color: #323232; display: flex; @@ -88,13 +88,13 @@ oppia-share-lesson-modal .success-message p { padding: 12px 16px; } -oppia-share-lesson-modal .copy-modal-body { +.copy-modal-body { align-items: center; display: flex; justify-content: center; } -oppia-share-lesson-modal .cc-attribution-header { +.cc-attribution-header { color: #0d48a1; font-family: "Roboto", sans-serif; font-size: 20px; @@ -103,11 +103,11 @@ oppia-share-lesson-modal .cc-attribution-header { text-align: center; } -oppia-share-lesson-modal .cc-attribution a:hover { +.cc-attribution a:hover { text-decoration: none; } -oppia-share-lesson-modal .cc-attribution-input { +.cc-attribution-input { background-color: #ddd; border: 1px solid #0d48a1; border-radius: 4px; @@ -115,7 +115,7 @@ oppia-share-lesson-modal .cc-attribution-input { padding: 10px; } -oppia-share-lesson-modal .copy-cc-btn { +.copy-cc-btn { border: 1px solid #205d86; border-radius: 4px; font-size: 17px; @@ -124,7 +124,7 @@ oppia-share-lesson-modal .copy-cc-btn { width: 49%; } -oppia-share-lesson-modal .copy-cc-btn .button-content { +.copy-cc-btn .button-content { align-items: center; color: #205d86; display: flex; @@ -132,34 +132,34 @@ oppia-share-lesson-modal .copy-cc-btn .button-content { justify-content: space-between; } -oppia-share-lesson-modal .cc-attribution-text { +.cc-attribution-text { display: flex; flex-direction: column; } @media (max-width: 480px) { - oppia-share-lesson-modal .oppia-share-lesson-modal-footer { + .oppia-share-lesson-modal-footer { justify-content: center; } - oppia-share-lesson-modal .lesson-attribution-text { + .lesson-attribution-text { font-size: 15px; } - oppia-share-lesson-modal .share-modal-btn { + .share-modal-btn { margin-bottom: 10px; margin-right: 0; } - oppia-share-lesson-modal .copy-modal-body-btn { + .copy-modal-body-btn { flex-direction: column; } - oppia-share-lesson-modal .cc-attribution-input { + .cc-attribution-input { width: 100%; } - oppia-share-lesson-modal .copy-cc-btn { + .copy-cc-btn { width: 100%; } } diff --git a/core/templates/pages/exploration-player-page/services/current-interaction.service.spec.ts b/core/templates/pages/exploration-player-page/services/current-interaction.service.spec.ts index 1900419585aab..ea972ac552cf6 100644 --- a/core/templates/pages/exploration-player-page/services/current-interaction.service.spec.ts +++ b/core/templates/pages/exploration-player-page/services/current-interaction.service.spec.ts @@ -264,6 +264,13 @@ describe('Current Interaction Service', () => { expect(currentInteractionService.showNoResponseError()).toBe(true); }); + it('should return false for no response error when no card is displayed', () => { + spyOn(currentInteractionService, 'getDisplayedCard').and.returnValue( + undefined + ); + + expect(currentInteractionService.showNoResponseError()).toBe(false); + }); it('should update answer validity using updateAnswerIsValid', () => { spyOn(currentInteractionService, 'getDisplayedCard').and.returnValue( displayedCard @@ -290,4 +297,12 @@ describe('Current Interaction Service', () => { expect(currentInteractionService.showInvalidResponseError()).toBe(false); expect(displayedCard.showInvalidResponseError).toHaveBeenCalledTimes(2); }); + + it('should return false for invalid response error when no card is displayed', () => { + spyOn(currentInteractionService, 'getDisplayedCard').and.returnValue( + undefined + ); + + expect(currentInteractionService.showInvalidResponseError()).toBe(false); + }); }); diff --git a/core/templates/pages/exploration-player-page/services/current-interaction.service.ts b/core/templates/pages/exploration-player-page/services/current-interaction.service.ts index 28ac4cf2dbbef..6058bc93cbba0 100644 --- a/core/templates/pages/exploration-player-page/services/current-interaction.service.ts +++ b/core/templates/pages/exploration-player-page/services/current-interaction.service.ts @@ -161,7 +161,7 @@ export class CurrentInteractionService { CurrentInteractionService.onSubmitFn(answer, interactionRulesService); } - getDisplayedCard(): StateCard { + getDisplayedCard(): StateCard | undefined { const index = this.playerPositionService.getDisplayedCardIndex(); return this.playerTranscriptService.getCard(index); } @@ -171,7 +171,7 @@ export class CurrentInteractionService { } showNoResponseError(): boolean { - return this.getDisplayedCard().showNoResponseError(); + return this.getDisplayedCard()?.showNoResponseError() ?? false; } submitAnswer(): void { @@ -242,6 +242,6 @@ export class CurrentInteractionService { this.getDisplayedCard()?.updateAnswerIsValid(isAnswerValid); } showInvalidResponseError(): boolean { - return this.getDisplayedCard().showInvalidResponseError(); + return this.getDisplayedCard()?.showInvalidResponseError() ?? false; } } diff --git a/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.spec.ts b/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.spec.ts index cf790d52b2d3b..3f80c08f96b6e 100644 --- a/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.spec.ts +++ b/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.spec.ts @@ -52,7 +52,7 @@ describe('Learner View Info Backend Api Service', () => { let req = httpTestingController.expectOne( '/explorationsummarieshandler/' + - 'data?stringified_exp_ids=&include_private_explorations=' + 'data?stringified_exp_ids=&include_private_explorations=&display_in_language_code=en' ); expect(req.request.method).toEqual('GET'); req.flush(jobOutput); diff --git a/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.ts b/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.ts index f48d3eb3d3cd8..60f55db535dcb 100644 --- a/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.ts +++ b/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.ts @@ -19,6 +19,7 @@ import {HttpClient} from '@angular/common/http'; import {Injectable} from '@angular/core'; import {AppConstants} from 'app.constants'; import {LearnerExplorationSummaryBackendDict} from 'domain/summary/learner-exploration-summary.model'; +import {I18nLanguageCodeService} from 'services/i18n-language-code.service'; interface LearnerViewBackendDict { summaries: LearnerExplorationSummaryBackendDict[]; @@ -28,7 +29,10 @@ interface LearnerViewBackendDict { providedIn: 'root', }) export class LearnerViewInfoBackendApiService { - constructor(private http: HttpClient) {} + constructor( + private http: HttpClient, + private i18nLanguageCodeService: I18nLanguageCodeService + ) {} async fetchLearnerInfoAsync( stringifiedExpIds: string, @@ -41,6 +45,11 @@ export class LearnerViewInfoBackendApiService { params: { stringified_exp_ids: stringifiedExpIds, include_private_explorations: includePrivateExplorations, + // The lesson information card shows this metadata alongside the + // lesson itself, so it is displayed in the learner's site + // language. + display_in_language_code: + this.i18nLanguageCodeService.getCurrentI18nLanguageCode(), }, } ) diff --git a/core/templates/pages/feedback-updates-page/feedback-updates-page.component.css b/core/templates/pages/feedback-updates-page/feedback-updates-page.component.css index 8464b436dab9e..0a439b0913d77 100644 --- a/core/templates/pages/feedback-updates-page/feedback-updates-page.component.css +++ b/core/templates/pages/feedback-updates-page/feedback-updates-page.component.css @@ -256,3 +256,16 @@ oppia-feedback-updates-page .oppia-pagination-button { .oppia-diagram-spinning-button { margin: 0 auto; } + +.oppia-profile-alignment { + vertical-align: middle; +} + +.oppia-box-size { + padding: 2px 10px; +} + +.feedback-updates-content-row { + display: flex; + justify-content: center; +} diff --git a/core/templates/pages/feedback-updates-page/feedback-updates-page.component.html b/core/templates/pages/feedback-updates-page/feedback-updates-page.component.html index 9019d8ecd2895..a5dae3b24740f 100644 --- a/core/templates/pages/feedback-updates-page/feedback-updates-page.component.html +++ b/core/templates/pages/feedback-updates-page/feedback-updates-page.component.html @@ -250,16 +250,3 @@ - - diff --git a/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.css b/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.css new file mode 100644 index 0000000000000..31e502cc3c41c --- /dev/null +++ b/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.css @@ -0,0 +1,135 @@ +.oppia-landing-page h2, button { + border-radius: 4px; + font-family: "Roboto", Arial, sans-serif; +} +.oppia-landing-page h2, h3, p { + line-height: 1.5; +} +.oppia-white-container { + background-color: #fff +} +.oppia-grey-container { + background-color: #e8e7e3 +} +.oppia-landing-section-inner { + margin-left: auto; + margin-right: auto; + max-width: 1200px; + padding: 4% 0; +} +.oppia-langing-page-qualities { + width: 30%; +} +.oppia-flex-box { + display: flex; + justify-content: center; +} +.oppia-flex-row { + flex-flow: row; +} +.oppia-flex-column { + flex-flow: column; +} +.oppia-flex-item { + padding: 5px; +} +.oppia-text-align-left { + text-align: left; +} +.oppia-flex-align-center { + align-items: center; +} +.oppia-landing-page-button { + background-color: #009688; + border: 4px solid #009688; + border-radius: 5px; + color: #fff; + margin: 14px 0; + min-width: 250px; + text-transform: uppercase; +} +.oppia-make-button-transparent { + background-color: transparent; + color: #009688; +} +.oppia-landing-page-image-container { + display: block; + margin: auto; + min-height: 150px; + min-width: 200px; + width: 80%; +} +.oppia-devices-image { + width: 80%; +} +.oppia-container-low-width { + width: 40%; +} +.oppia-only-display-in-mobile { + display: none; +} +.oppia-container-margin { + margin: 10px; +} +.oppia-landing-page-button:hover, +.oppia-landing-page-button:focus, +.oppia-landing-page-button:active { + background-color: #015c53; + border-color: #015c53; + color: #fff; +} +.oppia-make-button-transparent:hover, +.oppia-make-button-transparent:focus, +.oppia-make-button-transparent:active { + background-color: transparent; + border-color: #004d45; + color: #004d45; +} + +@media screen and (max-width: 780px) { + .oppia-landing-section-inner, + .oppia-landing-section { + height: auto; + } + .oppia-landing-page-button { + margin: 0 0 14px; + } + .oppia-flex-mobile-column { + flex-flow: column; + } + .oppia-flex-mobile-order-2 { + order: 2; + } + .oppia-flex-mobile-order-3 { + order: 3; + } + .oppia-flex-mobile-row { + flex-flow: row; + } + .oppia-flex-mobile-align-center { + align-items: center; + } + .oppia-mobile-text-align-center { + text-align: center; + } + .oppia-landing-page-image-container { + min-height: unset; + min-width: unset; + width: 35%; + } + .oppia-langing-page-qualities { + width: 100%; + } + .oppia-landing-qualities-description { + width: 65%; + } + .oppia-container-low-width { + width: 75%; + } + .oppia-only-display-in-mobile { + display: block; + } + .oppia-container-margin { + margin: 0; + } +} diff --git a/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.html b/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.html index e4e8dbf54eeae..e248805e0b045 100644 --- a/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.html +++ b/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.html @@ -74,141 +74,3 @@

Get started today! Looking for something else? Check out all of our other le - - diff --git a/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.ts b/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.ts index 73519a678fb4c..be469bda0e2a4 100644 --- a/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.ts +++ b/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.ts @@ -28,6 +28,7 @@ import {UrlService} from 'services/contextual/url.service'; import {WindowRef} from 'services/contextual/window-ref.service'; import {SiteAnalyticsService} from 'services/site-analytics.service'; import {PageTitleService} from 'services/page-title.service'; +import './topic-landing-page.component.css'; type TopicLandingPageDataKey = keyof typeof TopicLandingPageConstants.TOPIC_LANDING_PAGE_DATA; @@ -53,7 +54,7 @@ interface TopicData { @Component({ selector: 'topic-landing-page', templateUrl: './topic-landing-page.component.html', - styleUrls: [], + styleUrls: ['./topic-landing-page.component.css'], }) export class TopicLandingPageComponent implements OnInit, OnDestroy { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.css b/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.css new file mode 100644 index 0000000000000..7c6f6e655ebe0 --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.css @@ -0,0 +1,107 @@ +.oppia-learner-dash-button--default[disabled], +.oppia-learner-dash-button--default:disabled { + background-color: #f2f2f2 !important; + border: 1px solid #ccc !important; + color: #888 !important; + cursor: not-allowed; + opacity: 0.6; + pointer-events: none; +} + +.oppia-learner-dash-goals-modal-content { + box-sizing: border-box; + display: grid; + gap: 12px 24px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + height: auto; + max-height: 165px; + overflow-y: auto; + padding: 12px 20px 0; + width: 100%; +} + +.oppia-learner-dash-goals-checkbox { + align-items: flex-start; + display: flex; + font-size: 16px; + gap: 0 12px; + min-height: 40px; + overflow: hidden; +} + +.oppia-learner-dash-goals-checkbox-text { + -webkit-box-orient: vertical; + display: -webkit-box; + -webkit-line-clamp: 2; + overflow: hidden; + text-overflow: ellipsis; + white-space: normal; + word-break: break-word; +} + +.oppia-learner-dash-goals-modal-actions { + bottom: 0; + display: flex; + gap: 12px; + justify-content: end; +} + +.oppia-learner-dash-button--modal { + flex: 1 1 auto; + height: 44px; + max-width: 178px; + min-width: 95px; +} + +.oppia-learner-dash-button--modal.outline { + border: 1px solid #00645c; +} + +.oppia-learner-dash-goals-modal-title { + font-size: 20px; + font-weight: 500; + text-align: center; +} + +.oppia-learner-dash-goals-modal-info { + font-size: 14px; + text-align: center; +} + +@media screen and (max-width: 1024px) { + .oppia-learner-dash-button--modal { + max-width: 85%; + } + + .oppia-learner-dash-goals-modal-content { + grid-template-columns: 1fr; + padding: 20px 16px; + } + + .oppia-learner-dash-goals-modal-actions { + flex-grow: 1; + justify-content: center; + } + + .oppia-learner-dash-goals-checkbox { + align-items: center; + display: flex; + min-height: 48px; + } +} + +@media screen and (max-width: 768px) { + + .oppia-learner-dash-goals-modal-content { + min-height: 256px; + } +} + +@media screen and (max-width: 576px) { + .oppia-learner-dash-goals-modal-actions { + gap: 0 0; + } + .oppia-learner-dash-button--modal { + flex: 1 1 45%; + } +} diff --git a/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.html b/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.html index 486425883b179..80f41ef6a1fc7 100644 --- a/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.html +++ b/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.html @@ -19,114 +19,3 @@

{{ 'I18N_LEAR - - diff --git a/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.ts b/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.ts index 084a09378c19f..da7ae44a8fd04 100644 --- a/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.ts +++ b/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.ts @@ -17,10 +17,12 @@ */ import {Component, Inject} from '@angular/core'; import {MatDialogRef, MAT_DIALOG_DATA} from '@angular/material/dialog'; +import './add-goals-modal.component.css'; @Component({ selector: 'oppia-add-goals-modal', templateUrl: './add-goals-modal.component.html', + styleUrls: ['./add-goals-modal.component.css'], }) export class AddGoalsModalComponent { checkedTopics: Set; diff --git a/core/templates/pages/learner-dashboard-page/card-display/card-display.component.css b/core/templates/pages/learner-dashboard-page/card-display/card-display.component.css new file mode 100644 index 0000000000000..a96cf0a0ed9c9 --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/card-display/card-display.component.css @@ -0,0 +1,58 @@ +:host { + max-width: fit-content; + overflow: hidden; +} + +.card-display-arrow-button { + align-items: center; + background-color: #00645c; + border: none; + border-radius: .25em; + color: #fff; + display: flex; + height: 24px; + justify-content: center; + transition: all 300ms ease-out; + width: 24px; +} + +.card-display-arrow-button:disabled { + background-color: #667085; +} + +.card-display-arrow-button:not(:disabled):hover { + background-color: #429488; +} + +.card-display-content-container { + display: flex; + gap: 0 16px; + max-width: 100%; + overflow-x: hidden; + padding: .5px; +} + +.card-display-content-hidden { + flex-wrap: wrap; + gap: 16px 16px; + height: 289px; + overflow-y: hidden; +} + +.card-display-content-shown { + flex-wrap: wrap; + gap: 16px 16px; + height: 100%; +} + +.card-display-heading { + color: #333; + font-size: 20px; + font-weight: 500; + margin-bottom: 0; +} + +.card-display-arrow-button-container { + display: flex; + gap: 0 5px; +} diff --git a/core/templates/pages/learner-dashboard-page/card-display/card-display.component.html b/core/templates/pages/learner-dashboard-page/card-display/card-display.component.html index 8fc9b4367b4d3..e8459821f4bdd 100644 --- a/core/templates/pages/learner-dashboard-page/card-display/card-display.component.html +++ b/core/templates/pages/learner-dashboard-page/card-display/card-display.component.html @@ -17,64 +17,3 @@ - - diff --git a/core/templates/pages/learner-dashboard-page/card-display/card-display.component.ts b/core/templates/pages/learner-dashboard-page/card-display/card-display.component.ts index d5e7f29fb7e69..f4138cd597e47 100644 --- a/core/templates/pages/learner-dashboard-page/card-display/card-display.component.ts +++ b/core/templates/pages/learner-dashboard-page/card-display/card-display.component.ts @@ -25,9 +25,11 @@ import { NgZone, } from '@angular/core'; import {I18nLanguageCodeService} from 'services/i18n-language-code.service'; +import './card-display.component.css'; @Component({ selector: 'oppia-card-display', templateUrl: './card-display.component.html', + styleUrls: ['./card-display.component.css'], }) export class CardDisplayComponent implements AfterContentInit { @Input() headingI18n!: string; diff --git a/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.css b/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.css new file mode 100644 index 0000000000000..5637c791b25ed --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.css @@ -0,0 +1,47 @@ +.oppia-learner-dash-button-lg { + align-items: center; + border: 1px solid; + border-radius: 4px; + display: inline-flex; + font-weight: 700; + gap: 0 4px; + justify-content: center; + max-width: 100%; + padding: 10px 0; + text-align: center; + text-decoration: none; + transition: all 300ms ease-out; + width: 344px; +} + +.oppia-learner-dash-button-lg:hover { + text-decoration: none; +} + +.oppia-learner-dash-button-lg:focus { + text-decoration: none; +} + +@media screen and (max-width: 500px) { + .oppia-learner-dash-button-lg { + padding: 10px 8px; + } +} + +.oppia-learner-dash-button--blue { + background-color: #fff; + border-color: #205d86; + color: #205d86; +} + +.oppia-learner-dash-button--blue:hover { + background-color: #cadbda; + border-color: #205d86; + color: #205d86; +} + +.oppia-learner-dash-button--blue:focus { + background-color: #429488; + border-color: #205d86; + color: #fff; +} diff --git a/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.html b/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.html index fa8941b17dad0..42dc7811111d3 100644 --- a/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.html +++ b/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.html @@ -3,53 +3,3 @@ {{ 'I18N_LEARNER_DASHBOARD_HOME_CLASSROOM_SECTION_LINK' | translate }} - - diff --git a/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.ts b/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.ts index 7326f764f326a..f4ac1a6277512 100644 --- a/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.ts +++ b/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.ts @@ -17,9 +17,11 @@ */ import {Component, Input} from '@angular/core'; import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service'; +import './classroom-button.component.css'; @Component({ selector: 'oppia-classroom-button', templateUrl: './classroom-button.component.html', + styleUrls: ['./classroom-button.component.css'], }) export class ClassroomButtonComponent { @Input() classroom!: string; diff --git a/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.css b/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.css new file mode 100644 index 0000000000000..2d3968a8b01f0 --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.css @@ -0,0 +1,21 @@ +.content-toggle-button { + align-items: center; + background-color: #fff; + border: none; + border-radius: .25em; + color: #00645c; + display: flex; + float: inline-end; + font-size: 14px; + font-weight: 500; + gap: 0 4px; + justify-content: center; + padding: 8px; + transition: all 300ms ease-out; + width: fit-content; +} + +.content-toggle-button:not(:disabled):hover .content-toggle-button-text { + text-decoration: underline; + text-underline-offset: 4px; +} diff --git a/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.html b/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.html index 454cd769768ef..7abd813a8b3ef 100644 --- a/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.html +++ b/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.html @@ -2,27 +2,3 @@ {{ buttonText }} - - diff --git a/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.ts b/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.ts index 3d2f6eb84b895..2004e8dc326cc 100644 --- a/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.ts +++ b/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.ts @@ -17,9 +17,11 @@ */ import {Component, EventEmitter, Output} from '@angular/core'; import {TranslateService} from '@ngx-translate/core'; +import './content-toggle-button.component.css'; @Component({ selector: 'oppia-content-toggle-button', templateUrl: './content-toggle-button.component.html', + styleUrls: ['./content-toggle-button.component.css'], }) export class ContentToggleButtonComponent { isExpanded: boolean = false; diff --git a/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.css b/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.css new file mode 100644 index 0000000000000..0edcc1643be2a --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.css @@ -0,0 +1,139 @@ +.goal-list-center { + align-items: center; + display: flex; + gap: 0 12px; + position: relative; +} + +.goal-list-container { + box-shadow: 0 2px 2px 0 #00000040; + width: 100%; +} + +.goal-list-details { + display: flex; + flex-direction: column; + height: 100%; + justify-content: space-between; + padding-bottom: 8px; + padding-top: 8px; +} + +.goal-list-details p { + font-family: 'Roboto', Arial, sans-serif; + font-weight: 500; +} + +.goal-list-img { + height: 96px; + width: 152px; +} + +.goal-list-options { + display: flex; + flex-direction: column; + height: 100%; + justify-content: space-between; + position: relative; +} + +.goal-list-progress-bar { + background-color: rgb(0, 100, 92, .24); + height: 4px; + max-width: 185px; + width: 100%; +} + +.goal-list-progress-bar>div { + background-color: rgb(0, 100, 92, 1); + height: 4px; +} + +.goal-list-progress-text { + font-size: 10px; + margin-bottom: 0; +} + +.goal-list-story-nodes { + display: flex; + flex-direction: column; + max-height: 276px; + overflow-y: auto; + padding-bottom: 12px; + padding-top: 12px; + scrollbar-color: #205d86; +} + +.goal-list-story-node-line { + border-left: 1px solid; + height: 12px; +} + +.goal-list-story-node-line-container { + display: flex; + justify-content: center; + width: 36px; +} + +.goal-list-stretch { + align-items: stretch; + display: flex; + gap: 0 12px; +} + +.goal-list-title { + font-size: 20px; +} + +.oppia-learner-dash-button--goals-list { + text-decoration: none; + width: 96px; +} + +.oppia-learner-dash-button--incomplete-goal { + opacity: 0; +} + +.oppia-learner-dash-button--incomplete-goal:hover { + opacity: 1; +} + +@media screen and (max-width: 500px) { + .goal-list-details { + width: 100%; + } + + .goal-list-img { + height: 187px; + width: 100%; + } + + .goal-list-options { + align-items: center; + flex-direction: row-reverse; + padding-top: 8px; + width: 100%; + } + + .goal-list-stretch { + flex-direction: column; + width: 100%; + } + + .goal-list-title { + font-size: 18px; + margin-bottom: 8px; + } +} +.oppia-learner-dash-button--disabled { + background-color: #f5f5f5; + border: 1px dashed #aaa; + border-radius: 4px; + color: #888; + cursor: default; + font-size: 14px; + font-weight: 500; + height: 36px; + padding: 10px 8px; + width: 122px; +} diff --git a/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.html b/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.html index 3ec430cd1ac63..231df016559a6 100644 --- a/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.html +++ b/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.html @@ -61,145 +61,3 @@ - - diff --git a/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.ts b/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.ts index 30de89dc05f15..0bd116ae750c1 100644 --- a/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.ts +++ b/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.ts @@ -26,10 +26,12 @@ import {LearnerTopicSummary} from 'domain/topic/learner-topic-summary.model'; import {StorySummary} from 'domain/story/story-summary.model'; import {StoryNode} from 'domain/story/story-node.model'; import {PlatformFeatureService} from 'services/platform-feature.service'; +import './goal-list.component.css'; @Component({ selector: 'oppia-goal-list', templateUrl: './goal-list.component.html', + styleUrls: ['./goal-list.component.css'], }) export class GoalListComponent implements OnInit { @Input() goalTopic!: LearnerTopicSummary; diff --git a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.css b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.css index 0726d368f6a82..ceaa7b8671462 100644 --- a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.css +++ b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.css @@ -688,3 +688,15 @@ oppia-learner-dashboard-page .oppia-learner-groups-icon { font-size: 24px; word-wrap: break-word; } + +.oppia-profile-alignment { + vertical-align: middle; +} +.oppia-box-size { + padding: 2px 10px; +} +.col-md-3 { + flex: 0 0 25%; + padding-left: 15px; + padding-right: 15px; +} diff --git a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.html b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.html index 2380405ecb831..5fb20573f1ab2 100644 --- a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.html +++ b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.html @@ -203,17 +203,3 @@ - - diff --git a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.ts b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.ts index 47a8eb0dbd7c7..9096fa90a8435 100644 --- a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.ts +++ b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.ts @@ -53,6 +53,7 @@ import {PageTitleService} from 'services/page-title.service'; import {LearnerGroupBackendApiService} from 'domain/learner_group/learner-group-backend-api.service'; import {UrlService} from 'services/contextual/url.service'; import {PlatformFeatureService} from 'services/platform-feature.service'; +import './learner-dashboard-page.component.css'; interface LearnerDashboardExplorationsData { completedExplorationsList: LearnerExplorationSummary[]; diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitaiton-modal.component.ts b/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitaiton-modal.component.ts index 253a0fe9d8eb4..7a8e3aeba8550 100644 --- a/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitaiton-modal.component.ts +++ b/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitaiton-modal.component.ts @@ -19,10 +19,12 @@ import {Component} 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 './decline-invitation-modal.component.css'; @Component({ selector: 'oppia-decline-invitation-modal', templateUrl: './decline-invitation-modal.component.html', + styleUrls: ['./decline-invitation-modal.component.css'], }) export class DeclineInvitationModalComponent extends ConfirmOrCancelModal { learnerGroupTitle!: string; diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitation-modal.component.css b/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitation-modal.component.css new file mode 100644 index 0000000000000..dc65574dd7989 --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitation-modal.component.css @@ -0,0 +1,13 @@ +.btn-primary { + background-color: #00645c; + border: solid 1px #00645c; + margin-right: 20px; + padding: 6px 16px; +} + +.btn-danger { + background: #fff; + border: solid 1px #a53417; + color: #a53417; + padding: 6px 16px; +} diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitation-modal.component.html b/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitation-modal.component.html index ced46303220b5..3d532c806d1cb 100644 --- a/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitation-modal.component.html +++ b/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitation-modal.component.html @@ -8,19 +8,3 @@

- - diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.css b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.css new file mode 100644 index 0000000000000..9d701c0397737 --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.css @@ -0,0 +1,26 @@ +.modal-header-title { + border-bottom: solid 1px #e0e0e0; +} + +.modal-header-title p { + margin: 0; +} + +.primary-color { + color: #00645c; +} + +.modal-header .btn-close { + cursor: pointer; + margin: 0.75rem 0 0.75rem auto; + opacity: 0.5; +} + +.modal-body .group-title, +.modal-body .group-description { + margin-bottom: 15px; +} + +.modal-footer { + display: none; +} diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.html b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.html index 78bb3adf2301b..460c00e75ef6b 100644 --- a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.html +++ b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.html @@ -28,32 +28,3 @@ - - diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.ts b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.ts index 1ad12012818b1..eac59fed709f4 100644 --- a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.ts +++ b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.ts @@ -20,10 +20,12 @@ import {Component} 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 {ShortLearnerGroupSummary} from 'domain/learner_group/short-learner-group-summary.model'; +import './view-learner-group-details-modal.component.css'; @Component({ selector: 'oppia-view-learner-group-details-modal', templateUrl: './view-learner-group-details-modal.component.html', + styleUrls: ['./view-learner-group-details-modal.component.css'], }) export class ViewLearnerGroupDetailsModalComponent extends ConfirmOrCancelModal { learnerGroup!: ShortLearnerGroupSummary; diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.css b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.css new file mode 100644 index 0000000000000..4afb61728a92d --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.css @@ -0,0 +1,45 @@ +.modal-header-title { + border-bottom: solid 1px #e0e0e0; +} + +.modal-header-title p { + margin: 0; +} + +.primary-color { + color: #00645c; +} + +.btn-primary { + background-color: #00645c; + border: solid 1px #00645c; + margin-right: 20px; + padding: 6px 16px; +} + +.btn-secondary { + border: solid 1px #00645c; + color: #00645c; + margin-right: 20px; + padding: 6px 16px; +} + +.btn-danger { + background: #fff; + border: solid 1px #a53417; + color: #a53417; + padding: 6px 16px; +} + +.modal-header .btn-close { + cursor: pointer; + margin: 0.75rem 0 0.75rem auto; + opacity: 0.5; +} + +.group-title, +.group-description, +.learner-groups-progress-sharing-info, +.group-facilitator { + margin-bottom: 15px; +} diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.html b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.html index 5d74af53f9c46..c63b7d3ed38da 100644 --- a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.html +++ b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.html @@ -43,51 +43,3 @@ - - diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.ts b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.ts index 4cf30778836e7..1a3351b48ee4d 100644 --- a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.ts +++ b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.ts @@ -20,10 +20,12 @@ import {Component} 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 {ShortLearnerGroupSummary} from 'domain/learner_group/short-learner-group-summary.model'; +import './view-learner-group-invitation-modal.component.css'; @Component({ selector: 'oppia-view-learner-group-invitation-modal', templateUrl: './view-learner-group-invitation-modal.component.html', + styleUrls: ['./view-learner-group-invitation-modal.component.css'], }) export class ViewLearnerGroupInvitationModalComponent extends ConfirmOrCancelModal { learnerGroup!: ShortLearnerGroupSummary; diff --git a/core/templates/pages/learner-dashboard-page/old-progress-tab.component.css b/core/templates/pages/learner-dashboard-page/old-progress-tab.component.css new file mode 100644 index 0000000000000..700a67f0c088b --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/old-progress-tab.component.css @@ -0,0 +1,313 @@ +.progress-tab .completed-stories { + background-color: #fff; + border-radius: 4px; + box-shadow: 0 0 4px #0000001a; + margin-top: 15px; + min-height: 342px; + opacity: 1; + padding: 0 0 30px 40px; + width: 736px; +} +.progress-tab .empty-completed-stories { + background-color: #fff; + border-radius: 4px; + box-shadow: 0 0 4px #0000001a; + margin-top: 15px; + min-height: 206px; + opacity: 1; + padding: 0 0 30px 40px; + width: 360px; +} +.progress-tab .skill-proficiency { + background-color: #fff; + border-radius: 4px; + box-shadow: 0 0 4px #0000001a; + min-height: 220px; + opacity: 1; + padding: 0 30px 30px 40px; + width: 736px; +} +#setting-a-goal-link { + color: #00645c; + font-weight: bold; +} +.progress-tab .empty-section { + padding: 0 113px 30px 40px; +} +.progress-tab .completed-stories .completed-stories-title, +.progress-tab .empty-completed-stories .completed-stories-title, +.progress-tab .skill-proficiency-title { + color: #00645c; + display: inline-block; + font-family: "Capriola", "Roboto", Arial, sans-serif; + font-size: 24px; + letter-spacing: 0; + opacity: 1; + text-align: left; +} +.progress-tab .empty-section .skill-proficiency-title { + margin-bottom: 0; +} +.progress-tab .empty-skill-proficiency-section { + font-size: 18px; +} +.progress-tab .empty-skill-proficiency { + display: inline; + font-size: 18px; + margin-bottom: 0; + text-align: left; +} +.oppia-badge-image { + margin-right: 12px; + width: 32px; +} +.oppia-badge-image-for-topic { + bottom: 6px; + float: left; + margin-right: 20px; + position: relative; + width: 31px; +} +.progress-tab .skill-proficiency-topic-tile { + border-bottom: 1px solid; + height: 43px; + margin-bottom: 16px; + width: 656px; +} +.progress-tab .skill-proficiency-topic-title { + color: #333; + font-size: 19px; + font-weight: 500; + letter-spacing: 0; + opacity: 1; + position: relative; + text-align: left; +} +.progress-tab .progress-text { + bottom: 20px; + color: #00645c; + float: right; + font-size: 14px; + letter-spacing: 0; + opacity: 1; + position: relative; + right: 20px; + text-align: left; +} +.progress-tab .circle { + background-color: #fff; + border-radius: 100%; + height: 16px; + left: 6px; + position: relative; + text-align: center; + top: 6px; + width: 16px; +} +.progress-tab .circle-border { + background-color: #00645c; + border-radius: 100%; + bottom: 24px; + display: inline-block; + height: 28px; + left: 230px; + margin-left: 30%; + position: relative; + text-align: center; + width: 28px; +} +.progress-tab .info-tooltip { + display: inline-block; + opacity: 1; + position: relative; + text-decoration: none; +} +.progress-tab .info-tooltip span { + background: white; + -moz-border-radius: 6px; + -webkit-border-radius: 6px; + border-radius: 6px; + color: black; + display: none; +} +.progress-tab .progress-translate { + display: inline-block; +} +.progress-tab .info-tooltip span img { + float: left; + margin: 0 8px 8px 0; +} +.progress-tab .info-tooltip:hover span { + border: 0.5px solid #ccc; + border-radius: 4px; + box-shadow: 0 3px 6px #00000029; + display: block; + height: 155px; + margin-left: 32px; + padding: 9px 16px; + position: absolute; + top: -37px; + width: 157px; + z-index: 2; +} +.progress-tab .badge-type { + color: #333; + font-size: 16px; + margin-bottom: 0; + text-align: left; +} +.progress-tab .badge-percent { + color: #555; + font-size: 12px; + font-style: italic; + text-align: left; +} +.progress-tab .bg-info { + background-color: #fff; + color: #000; + margin-bottom: 16px; +} +.progress-tab .completed-stories-content { + display: inline-block; +} +.progress-tab .story-tile { + display: inline-block; + padding: 0 30px 0 0; +} +.progress-tab .empty-completed-stories-content { + font-size: 16px; + padding: 0 32px 0 0; +} +.progress-tab .completed-stories .completed-stories-heading, +.progress-tab .empty-completed-stories .completed-stories-heading, +.progress-tab .skill-proficiency-heading { + padding: 32px 0; +} +.progress-tab .completed-stories-length { + bottom: 3px; + font-size: 18px; + margin: 0 4px 0 12px; + position: relative; +} +.progress-tab .oppia-svg-star-image { + bottom: 4px; + filter: invert(95%) sepia(49%) saturate(2374%) hue-rotate(320deg) brightness(91%) contrast(89%); + position: relative; + width: 20px; +} +.progress-tab .skills-text { + color: #333; + font-size: 18px; + margin-bottom: 0; + text-align: left; + text-transform: uppercase; +} +.progress-tab .oppia-svg-dropdown-image, +.progress-tab .oppia-svg-up-image { + bottom: 5px; + float: right; + position: relative; + top: 2px; + width: 30px; +} +.progress-tab .oppia-svg-info-image { + bottom: 3px; + left: 10px; + position: relative; + width: 20px; +} +@media screen and (min-width: 768px) and (max-width: 950px) { + .progress-tab .completed-stories { + margin-top: 35px; + padding: 0 0 30px 25px; + position: relative; + right: 20px; + width: 555px; + } + .progress-tab .empty-completed-stories { + position: relative; + right: 25px; + width: 555px; + } + .progress-tab .circle-border { + left: 88px; + } + .progress-tab .scroll-content { + overflow: scroll; + } + .progress-tab .oppia-story-completion { + overflow: hidden; + } + .progress-tab .skill-proficiency { + position: relative; + right: 21px; + width: 555px; + } + .progress-tab .skill-proficiency-topic-tile { + width: 455px; + } + .progress-tab .skills-text { + font-size: 18px; + } + .progress-tab .skill-proficiency-title { + font-size: 20px; + margin-bottom: 0; + } +} +@media screen and (max-width: 767px) { + .progress-tab .empty-completed-stories { + margin: auto; + } + .progress-tab .completed-stories { + margin: auto; + margin-top: 35px; + padding: 0 0 30px 25px; + width: 360px; + } + .progress-tab .skill-proficiency { + margin: auto; + width: 360px; + } + .progress-tab .skill-proficiency-topic-tile { + height: 52px; + width: 280px; + } + .progress-tab .circle-border { + display: none; + } + .progress-tab .skill-proficiency-topic-title { + font-size: 16px; + } + .progress-tab .skills-text { + display: none; + } + .progress-tab .progress-text { + display: contents; + font-style: italic; + } + .progress-tab .skill-proficiency-title, + .progress-tab .completed-stories-title { + font-size: 20px; + margin-bottom: 0; + } + .progress-tab .story-tile { + padding: 0 14px 0 0; + } + .progress-tab .scroll-content { + overflow: scroll; + } + .progress-tab .skill-proficiency-heading { + padding: 23px 0; + } + .progress-tab .oppia-story-completion { + overflow: hidden; + } + .progress-tab .empty-section { + padding: 0 50px 30px 40px; + } +} +@media(max-width: 430px) { + .progress-tab .empty-completed-stories { + width: 355px; + } +} diff --git a/core/templates/pages/learner-dashboard-page/old-progress-tab.component.html b/core/templates/pages/learner-dashboard-page/old-progress-tab.component.html index eec665a9077ae..7cc05a8ae3a8a 100644 --- a/core/templates/pages/learner-dashboard-page/old-progress-tab.component.html +++ b/core/templates/pages/learner-dashboard-page/old-progress-tab.component.html @@ -131,318 +131,3 @@ - diff --git a/core/templates/pages/learner-dashboard-page/old-progress-tab.component.ts b/core/templates/pages/learner-dashboard-page/old-progress-tab.component.ts index ba805fbafe1a3..4bfbf9e3e72fe 100644 --- a/core/templates/pages/learner-dashboard-page/old-progress-tab.component.ts +++ b/core/templates/pages/learner-dashboard-page/old-progress-tab.component.ts @@ -28,10 +28,12 @@ import { } from 'domain/learner_dashboard/learner-dashboard-backend-api.service'; import {Subscription} from 'rxjs'; import {WindowDimensionsService} from 'services/contextual/window-dimensions.service'; +import './old-progress-tab.component.css'; @Component({ selector: 'oppia-old-progress-tab', templateUrl: './old-progress-tab.component.html', + styleUrls: ['./old-progress-tab.component.css'], }) export class OldProgressTabComponent implements OnInit { @Output() setActiveSection: EventEmitter = new EventEmitter(); diff --git a/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.css b/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.css new file mode 100644 index 0000000000000..94be3837d0ed4 --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.css @@ -0,0 +1,50 @@ +.oppia-learner-dash-button--skill { + background-color: transparent; + border-color: #fff; + color: #fff; + width: 122px; +} + +.oppia-learner-dash-button--skill:hover { + background-color: #fff; + color: #c55f45; +} + +.skill-card-container { + background-color: #c55f45; + border-radius: 4px; + box-shadow: 0 2px 2px 0 #00000040; + height: 260px; + padding: 0; + width: 216px; +} + +.skill-card-info { + border-radius: 0 0 4px 4px; + height: 124px; + padding: 8px; +} + +.skill-card-img { + height: 136px; + max-height: 100%; + position: relative; +} + +.skill-card-title { + color: #fff; + font-family: Capriola, Roboto, Arial, sans-serif; + font-size: 15px; + -webkit-line-clamp: 2; + line-height: 20px; +} + +.skill-card-title-container { + height: 40px; +} + +.skill-card-topic { + color: #fff; + font-size: 12px; + font-weight: 600; +} diff --git a/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.html b/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.html index 321ae39720bf0..7b4a8c28459a1 100644 --- a/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.html +++ b/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.html @@ -22,56 +22,3 @@ - - diff --git a/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.ts b/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.ts index 367a5f77e6a72..2fbff20b4f4fb 100644 --- a/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.ts +++ b/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.ts @@ -26,9 +26,11 @@ import {AssetsBackendApiService} from 'services/assets-backend-api.service'; import {LoaderService} from 'services/loader.service'; import {SiteAnalyticsService} from 'services/site-analytics.service'; import {WindowRef} from 'services/contextual/window-ref.service'; +import './skill-card.component.css'; @Component({ selector: 'oppia-skill-card', templateUrl: './skill-card.component.html', + styleUrls: ['./skill-card.component.css'], }) export class SkillCardComponent { @Input() topic!: LearnerTopicSummary; diff --git a/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.css b/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.css new file mode 100644 index 0000000000000..7386e064ea906 --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.css @@ -0,0 +1,8 @@ +.oppia-learner-dashboard-suggestion-body .oppia-no-current-state { + color: red; +} +.oppia-learner-dashboard-suggestion-body .oppia-dashboard-suggestin-description-text { + margin-left: 10px; + margin-right: 10px; + margin-top: 20px; +} diff --git a/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.html b/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.html index 05fc5b1d362fe..7d4d3107012b0 100644 --- a/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.html +++ b/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.html @@ -34,13 +34,3 @@

- diff --git a/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.ts b/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.ts index bdb1a9385082a..a5f41ca6fdc98 100644 --- a/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.ts +++ b/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.ts @@ -20,11 +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 './learner-dashboard-suggestion-modal.component.css'; @Component({ selector: 'oppia-learner-dashboard-suggestion-modal', templateUrl: './learner-dashboard-suggestion-modal.component.html', - styleUrls: [], + styleUrls: ['./learner-dashboard-suggestion-modal.component.css'], }) export class LearnerDashboardSuggestionModalComponent extends ConfirmOrCancelModal { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.css b/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.css new file mode 100644 index 0000000000000..dc65574dd7989 --- /dev/null +++ b/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.css @@ -0,0 +1,13 @@ +.btn-primary { + background-color: #00645c; + border: solid 1px #00645c; + margin-right: 20px; + padding: 6px 16px; +} + +.btn-danger { + background: #fff; + border: solid 1px #a53417; + color: #a53417; + padding: 6px 16px; +} diff --git a/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.html b/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.html index 1f26029901c58..b49d18453a0f3 100644 --- a/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.html +++ b/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.html @@ -12,19 +12,3 @@

- - diff --git a/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.ts b/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.ts index fc4c437aa0651..287e182e1e95e 100644 --- a/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.ts +++ b/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.ts @@ -19,10 +19,12 @@ import {Component} 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 './delete-learner-group-modal.component.css'; @Component({ selector: 'oppia-delete-learner-group-modal', templateUrl: './delete-learner-group-modal.component.html', + styleUrls: ['./delete-learner-group-modal.component.css'], }) export class DeleteLearnerGroupModalComponent extends ConfirmOrCancelModal { learnerGroupTitle!: string; diff --git a/core/templates/pages/library-page/library-page.component.html b/core/templates/pages/library-page/library-page.component.html index dcb60fbb86358..1ed51925451ea 100644 --- a/core/templates/pages/library-page/library-page.component.html +++ b/core/templates/pages/library-page/library-page.component.html @@ -111,7 +111,8 @@

[ratings]="tile.ratings" [numViews]="tile.num_views" [thumbnailIconUrl]="tile.thumbnail_icon_url" - [thumbnailBgColor]="tile.thumbnail_bg_color"> + [thumbnailBgColor]="tile.thumbnail_bg_color" + [translatedMetadataFields]="tile.translated_metadata_fields || []"> @@ -206,7 +207,8 @@

+ [showLearnerDashboardIconsIfPossible]="true" + [translatedMetadataFields]="tile.translated_metadata_fields || []"> diff --git a/core/templates/pages/library-page/library-page.component.spec.ts b/core/templates/pages/library-page/library-page.component.spec.ts index f39799d7f98f1..f2e86a8eb659c 100644 --- a/core/templates/pages/library-page/library-page.component.spec.ts +++ b/core/templates/pages/library-page/library-page.component.spec.ts @@ -50,6 +50,7 @@ import {SearchService} from 'services/search.service'; import {UserService} from 'services/user.service'; import {MockTranslateModule} from 'tests/unit-test-utils'; import {LibraryPageComponent} from './library-page.component'; +import {LibraryPageConstants} from './library-page.constants'; import { ActivityDict, LibraryIndexData, @@ -725,14 +726,19 @@ describe('Library Page Component', () => { it('should unsubscribe on component destruction', () => { componentInstance.translateSubscription = new Subscription(); componentInstance.resizeSubscription = new Subscription(); + componentInstance.i18nLanguageCodeSubscription = new Subscription(); spyOn(componentInstance.translateSubscription, 'unsubscribe'); spyOn(componentInstance.resizeSubscription, 'unsubscribe'); + spyOn(componentInstance.i18nLanguageCodeSubscription, 'unsubscribe'); componentInstance.ngOnDestroy(); expect( componentInstance.translateSubscription.unsubscribe ).toHaveBeenCalled(); expect(componentInstance.resizeSubscription.unsubscribe).toHaveBeenCalled(); + expect( + componentInstance.i18nLanguageCodeSubscription.unsubscribe + ).toHaveBeenCalled(); }); it('should get all classrooms data', fakeAsync(() => { @@ -978,4 +984,32 @@ describe('Library Page Component', () => { expect(componentInstance.leftmostCardIndices[ind]).toBe(2); }); + + it('should reload library data on site language change', fakeAsync(() => { + spyOn(componentInstance, 'loadLibraryData'); + componentInstance.ngOnInit(); + tick(); + // The initial load in ngOnInit is discounted so that the assertion below + // only passes if the language change itself triggered a reload. + (componentInstance.loadLibraryData as jasmine.Spy).calls.reset(); + + i18nLanguageCodeService.onI18nLanguageCodeChange.emit(); + tick(); + + expect(componentInstance.loadLibraryData).toHaveBeenCalled(); + })); + + it('should not reload library data on site language change in search mode', fakeAsync(() => { + spyOn(componentInstance, 'loadLibraryData'); + componentInstance.ngOnInit(); + tick(); + + componentInstance.pageMode = LibraryPageConstants.LIBRARY_PAGE_MODES.SEARCH; + (componentInstance.loadLibraryData as jasmine.Spy).calls.reset(); + + i18nLanguageCodeService.onI18nLanguageCodeChange.emit(); + tick(); + + expect(componentInstance.loadLibraryData).not.toHaveBeenCalled(); + })); }); diff --git a/core/templates/pages/library-page/library-page.component.ts b/core/templates/pages/library-page/library-page.component.ts index d3265b7dcec69..a3066c7cfbb5d 100755 --- a/core/templates/pages/library-page/library-page.component.ts +++ b/core/templates/pages/library-page/library-page.component.ts @@ -86,9 +86,11 @@ export class LibraryPageComponent { // https://github.com/oppia/oppia/wiki/Guide-on-defining-types#ts-7-1 translateSubscription!: Subscription; resizeSubscription!: Subscription; + i18nLanguageCodeSubscription!: Subscription; // The following property will be assigned null when user // has not selected any active group index. activeGroupIndex!: number | null; + preferredLanguageCodesLoaded: boolean = false; activityList!: ActivityDict[]; bannerImageFilename!: string; bannerImageFileUrl!: string; @@ -334,7 +336,6 @@ export class LibraryPageComponent { this.libraryWindowIsNarrow = this.windowDimensionsService.getWidth() <= libraryWindowCutoffPx; - this.loaderService.showLoadingScreen('I18N_LIBRARY_LOADING'); this.bannerImageFilename = this.possibleBannerFilenames[ Math.floor(Math.random() * this.possibleBannerFilenames.length) @@ -382,6 +383,31 @@ export class LibraryPageComponent { // i.e. if they are in a collapsed state or not. this.mobileLibraryGroupsProperties = []; + this.loadLibraryData(); + + this.i18nLanguageCodeSubscription = + this.i18nLanguageCodeService.onI18nLanguageCodeChange.subscribe(() => { + // The tiles show exploration metadata in the site language, so the + // data is fetched again when the learner changes that language. In + // search mode the tiles come from the search bar's own query instead. + if (this.pageMode !== LibraryPageConstants.LIBRARY_PAGE_MODES.SEARCH) { + this.loadLibraryData(); + } + }); + + this.resizeSubscription = this.windowDimensionsService + .getResizeEvent() + .subscribe(evt => { + this.initCarousels(); + + this.libraryWindowIsNarrow = + this.windowDimensionsService.getWidth() <= libraryWindowCutoffPx; + }); + } + + loadLibraryData(): void { + this.loaderService.showLoadingScreen('I18N_LIBRARY_LOADING'); + if (this.pageMode === LibraryPageConstants.LIBRARY_PAGE_MODES.GROUP) { let pathnameArray = this.windowRef.nativeWindow.location.pathname.split('/'); @@ -392,9 +418,12 @@ export class LibraryPageComponent { .then(response => { this.activityList = response.activity_list; this.groupHeaderI18nId = response.header_i18n_id; - this.i18nLanguageCodeService.onPreferredLanguageCodesLoaded.emit( - response.preferred_language_codes - ); + if (!this.preferredLanguageCodesLoaded) { + this.i18nLanguageCodeService.onPreferredLanguageCodesLoaded.emit( + response.preferred_language_codes + ); + this.preferredLanguageCodesLoaded = true; + } this.loaderService.hideLoadingScreen(); this.initCarousels(); }); @@ -466,9 +495,12 @@ export class LibraryPageComponent { } }); - this.i18nLanguageCodeService.onPreferredLanguageCodesLoaded.emit( - response.preferred_language_codes - ); + if (!this.preferredLanguageCodesLoaded) { + this.i18nLanguageCodeService.onPreferredLanguageCodesLoaded.emit( + response.preferred_language_codes + ); + this.preferredLanguageCodesLoaded = true; + } // The following initializes the tracker to have all // elements flush left. @@ -480,6 +512,7 @@ export class LibraryPageComponent { // The following initializes the array so that every group // (in mobile view) loads in with a limit on the number of cards // displayed, and with the button text being "See More". + this.mobileLibraryGroupsProperties = []; for (let i = 0; i < this.libraryGroups.length; i++) { this.mobileLibraryGroupsProperties.push({ inCollapsedState: true, @@ -488,15 +521,6 @@ export class LibraryPageComponent { } }); } - - this.resizeSubscription = this.windowDimensionsService - .getResizeEvent() - .subscribe(evt => { - this.initCarousels(); - - this.libraryWindowIsNarrow = - this.windowDimensionsService.getWidth() <= libraryWindowCutoffPx; - }); } moveClassroomCarouselToPreviousSlide(): void { @@ -569,5 +593,8 @@ export class LibraryPageComponent { if (this.resizeSubscription) { this.resizeSubscription.unsubscribe(); } + if (this.i18nLanguageCodeSubscription) { + this.i18nLanguageCodeSubscription.unsubscribe(); + } } } diff --git a/core/templates/pages/library-page/search-bar/search-bar.component.spec.ts b/core/templates/pages/library-page/search-bar/search-bar.component.spec.ts index 95fc63a2e4fc2..44e3d6ae9e83b 100644 --- a/core/templates/pages/library-page/search-bar/search-bar.component.spec.ts +++ b/core/templates/pages/library-page/search-bar/search-bar.component.spec.ts @@ -528,4 +528,22 @@ describe('Search bar component', () => { // @ts-ignore component.openSubmenu(null, null); }); + + it('should re-execute search on language change when on search find page', () => { + spyOn(component, 'onSearchQueryChangeExec'); + windowRef.nativeWindow.location.pathname = '/search/find'; + + i18nLanguageCodeService.onI18nLanguageCodeChange.emit(); + + expect(component.onSearchQueryChangeExec).toHaveBeenCalled(); + }); + + it('should not re-execute search on language change when not on search find page', () => { + spyOn(component, 'onSearchQueryChangeExec'); + windowRef.nativeWindow.location.pathname = '/community-library'; + + i18nLanguageCodeService.onI18nLanguageCodeChange.emit(); + + expect(component.onSearchQueryChangeExec).not.toHaveBeenCalled(); + }); }); diff --git a/core/templates/pages/library-page/search-bar/search-bar.component.ts b/core/templates/pages/library-page/search-bar/search-bar.component.ts index 6c1b164c72f73..7628d178243f0 100755 --- a/core/templates/pages/library-page/search-bar/search-bar.component.ts +++ b/core/templates/pages/library-page/search-bar/search-bar.component.ts @@ -375,6 +375,21 @@ export class SearchBarComponent implements OnInit, OnDestroy { this.refreshSearchBarLabels() ) ); + + // Result titles and objectives are rendered in the site language, so the + // results are re-fetched when it changes. This listens to the language + // code service rather than to onLangChange, because for a logged-in user + // the code is only updated after their preference has been saved, and + // re-fetching any earlier would send the previous language. Pages other + // than the search results page load their own tiles, so they are left to + // refresh themselves. + this.directiveSubscriptions.add( + this.i18nLanguageCodeService.onI18nLanguageCodeChange.subscribe(() => { + if (this.windowRef.nativeWindow.location.pathname === '/search/find') { + this.onSearchQueryChangeExec(); + } + }) + ); } ngOnDestroy(): void { diff --git a/core/templates/pages/library-page/search-results/activity-tiles-infinity-grid.component.html b/core/templates/pages/library-page/search-results/activity-tiles-infinity-grid.component.html index 56ceb921c7a34..89fe48c81280e 100644 --- a/core/templates/pages/library-page/search-results/activity-tiles-infinity-grid.component.html +++ b/core/templates/pages/library-page/search-results/activity-tiles-infinity-grid.component.html @@ -30,6 +30,7 @@ [isCommunityOwned]="activity.community_owned" [showLearnerDashboardIconsIfPossible]="true" [isContainerNarrow]="libraryWindowIsNarrow" + [translatedMetadataFields]="activity.translated_metadata_fields || []" class="e2e-test-exp-summary-tile"> diff --git a/core/templates/pages/library-page/services/library-page-backend-api.service.spec.ts b/core/templates/pages/library-page/services/library-page-backend-api.service.spec.ts index 25e876aac1d66..f4059e5c461d6 100644 --- a/core/templates/pages/library-page/services/library-page-backend-api.service.spec.ts +++ b/core/templates/pages/library-page/services/library-page-backend-api.service.spec.ts @@ -50,7 +50,9 @@ describe('Library page backend api service', () => { lpbas.fetchLibraryIndexDataAsync().then(successHandler, failHandler); - let req = httpTestingController.expectOne('/libraryindexhandler'); + let req = httpTestingController.expectOne( + '/libraryindexhandler?display_in_language_code=en' + ); expect(req.request.method).toEqual('GET'); req.flush(resp); @@ -92,7 +94,7 @@ describe('Library page backend api service', () => { lpbas.fetchLibraryGroupDataAsync('g1').then(successHandler, failHandler); let req = httpTestingController.expectOne( - '/librarygrouphandler?group_name=g1' + '/librarygrouphandler?group_name=g1&display_in_language_code=en' ); expect(req.request.method).toEqual('GET'); req.flush(resp); diff --git a/core/templates/pages/library-page/services/library-page-backend-api.service.ts b/core/templates/pages/library-page/services/library-page-backend-api.service.ts index 056e44b402b74..7a7f046d16c62 100644 --- a/core/templates/pages/library-page/services/library-page-backend-api.service.ts +++ b/core/templates/pages/library-page/services/library-page-backend-api.service.ts @@ -22,6 +22,8 @@ import {Injectable} from '@angular/core'; import {CollectionSummaryBackendDict} from 'domain/collection/collection-summary.model'; import {CreatorExplorationSummaryBackendDict} from 'domain/summary/creator-exploration-summary.model'; +import {TranslatableExplorationMetadataField} from 'domain/summary/learner-exploration-summary.model'; +import {I18nLanguageCodeService} from 'services/i18n-language-code.service'; interface LibraryGroupDataBackendDict { activity_list: ActivityDict[]; @@ -42,6 +44,7 @@ export interface ActivityDict { thumbnail_bg_color: string; thumbnail_icon_url: string; title: string; + translated_metadata_fields?: TranslatableExplorationMetadataField[]; } export interface SummaryDict { activity_summary_dicts: ActivityDict[]; @@ -81,11 +84,25 @@ export class LibraryPageBackendApiService { CREATOR_DASHBOARD_HANDLER: string = '/creatordashboardhandler/data'; LIBRARY_GROUP_HANDLER: string = '/librarygrouphandler'; - constructor(private http: HttpClient) {} + constructor( + private http: HttpClient, + private i18nLanguageCodeService: I18nLanguageCodeService + ) {} + + // The cards are displayed in the learner's site language, so that the + // exploration metadata matches the rest of the page. + private getDisplayLanguageParams(): {display_in_language_code: string} { + return { + display_in_language_code: + this.i18nLanguageCodeService.getCurrentI18nLanguageCode(), + }; + } async fetchLibraryIndexDataAsync(): Promise { return this.http - .get(this.LIBRARY_INDEX_HANDLER) + .get(this.LIBRARY_INDEX_HANDLER, { + params: this.getDisplayLanguageParams(), + }) .toPromise(); } @@ -102,6 +119,7 @@ export class LibraryPageBackendApiService { .get(this.LIBRARY_GROUP_HANDLER, { params: { group_name: groupName, + ...this.getDisplayLanguageParams(), }, }) .toPromise(); diff --git a/core/templates/pages/practice-session-page/practice-session-page.component.spec.ts b/core/templates/pages/practice-session-page/practice-session-page.component.spec.ts index 03054f067a882..69ec883e515a5 100644 --- a/core/templates/pages/practice-session-page/practice-session-page.component.spec.ts +++ b/core/templates/pages/practice-session-page/practice-session-page.component.spec.ts @@ -386,4 +386,32 @@ describe('Practice session page', () => { 'abbrev-topic/mastery-challenge' ); })); + + it('should append arc mastered markers to dashboard URL for arc sessions', fakeAsync(() => { + mockPlatformFeatureService.status.StoryEditorArcs.isEnabled = true; + spyOn(urlService, 'getArcIdFromUrl').and.returnValue('1'); + spyOn(urlService, 'getNodeIdFromPracticeUrl').and.returnValue(null); + spyOn(urlService, 'getPathname').and.returnValue( + '/learn/math/fractions/test/arc/1' + ); + spyOn( + practiceSessionsBackendApiService, + 'fetchPracticeSessionsData' + ).and.returnValue( + Promise.resolve({ + skill_ids_to_descriptions_map: {}, + topic_name: 'Fractions', + }) + ); + + component.ngOnInit(); + tick(); + + const dashboardActionButton = + component.questionPlayerConfig.resultActionButtons.find( + (button: {type: string}) => button.type === 'DASHBOARD' + ); + expect(dashboardActionButton?.url).toContain('arc_mastered=true'); + expect(dashboardActionButton?.url).toContain('arc_id=1'); + })); }); diff --git a/core/templates/pages/practice-session-page/practice-session-page.component.ts b/core/templates/pages/practice-session-page/practice-session-page.component.ts index 38f64fbda4fca..04ce670bbe5c5 100644 --- a/core/templates/pages/practice-session-page/practice-session-page.component.ts +++ b/core/templates/pages/practice-session-page/practice-session-page.component.ts @@ -176,7 +176,7 @@ export class PracticeSessionPageComponent implements OnInit, OnDestroy { this.urlService.getTopicUrlFragmentFromLearnerUrl(); const practiceSessionsDataUrl = this._getDataUrl(); const practiceSessionsUrl = this._getRetryUrl(); - const topicViewerUrl = this.urlInterpolationService.interpolateUrl( + let topicViewerUrl = this.urlInterpolationService.interpolateUrl( PracticeSessionPageConstants.TOPIC_VIEWER_PAGE, { topic_url_fragment: topicUrlFragment, @@ -185,6 +185,19 @@ export class PracticeSessionPageComponent implements OnInit, OnDestroy { } ); + if (this.sessionType === PracticeSessionType.Arc && this.arcId) { + topicViewerUrl = this.urlService.addField( + topicViewerUrl, + 'arc_mastered', + 'true' + ); + topicViewerUrl = this.urlService.addField( + topicViewerUrl, + 'arc_id', + this.arcId + ); + } + this.practiceSessionsBackendApiService .fetchPracticeSessionsData(practiceSessionsDataUrl) .then(result => { diff --git a/core/templates/pages/topic-editor-page/preview-tab/topic-preview-tab.component.html b/core/templates/pages/topic-editor-page/preview-tab/topic-preview-tab.component.html index 79b869ffffb53..e7c8ce7c7318f 100644 --- a/core/templates/pages/topic-editor-page/preview-tab/topic-preview-tab.component.html +++ b/core/templates/pages/topic-editor-page/preview-tab/topic-preview-tab.component.html @@ -1,78 +1,91 @@
-
- -
-
-
-
-
- -
-
- Learn -
-
-
-
- -
-
- Practice + + +
+
+ +
+
+
+
+
+ +
+
+ Learn +
-
-
-
- +
+
+ +
+
+ Practice +
-
- Study +
+
+ +
+
+ Study +
-
-
-
- - -
-
- +
+ + +
+
+ + +
+
+ - -
-
- - - -

-

-
-
-
+ [topicUrlFragment]="topicUrlFragment" + [classroomUrlFragment]="classroomUrlFragment"> + + +

+

+
+
+
+
@@ -138,6 +151,17 @@

host element. */ +.redesigned-view .oppia-page-card { + margin-top: 0; +} + +.redesigned-view .oppia-page-card.oppia-long-text { + margin-left: auto; + margin-right: auto; + width: 100%; +} + +.redesigned-view .oppia-subtopics-list-container { + margin: 0 auto; + max-width: 1160px; + width: 100%; +} + +.redesigned-view .oppia-page-card, +.redesigned-view .oppia-page-card.oppia-long-text { + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + margin: 0; + max-width: 100%; + padding: 0; + width: 100%; +} + +.redesigned-view .tab-title { + color: #00645c; + font-family: Capriola, Roboto, Arial, sans-serif; + font-size: 32px; + font-weight: 400; + line-height: 1.2; + margin: 0 0 8px; +} + +.redesigned-view .subtopic-subheading { + color: #4a4a4a; + font-family: Roboto, Arial, sans-serif; + font-size: 14px; + line-height: 1.35; + margin-bottom: 24px; +} + +.redesigned-view .subtopics-container { + display: grid; + gap: 28px; + grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); + justify-content: start; + margin-top: 0; +} + +.redesigned-view .subtopic-summary-tile { + margin-right: 0; +} + subtopics-list.topic-preview-mode .oppia-page-card.oppia-long-text { box-sizing: border-box; margin: 0; @@ -71,3 +130,46 @@ subtopics-list.topic-preview-mode .oppia-page-card.oppia-long-text { padding-top: 24px; width: 100%; } + +@media(max-width: 1024px) { + .redesigned-view .tab-title { + font-size: 34px; + } + + .redesigned-view .subtopic-subheading { + font-size: 18px; + } +} + +@media(max-width: 768px) { + .redesigned-view .tab-title { + font-size: 28px; + } + + .redesigned-view .subtopic-subheading { + font-size: 16px; + margin-bottom: 18px; + } + + .redesigned-view .subtopics-container { + gap: 18px; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + } +} + +@media(max-width: 500px) { + .redesigned-view .tab-title { + font-size: 25px; + line-height: 1.25; + } + + .redesigned-view .subtopic-subheading { + font-size: 16px; + margin-bottom: 16px; + } + + .redesigned-view .subtopics-container { + gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + } +} diff --git a/core/templates/pages/topic-viewer-page/topic-header/topic-header.component.spec.ts b/core/templates/pages/topic-viewer-page/topic-header/topic-header.component.spec.ts index d5b8ef662647b..44bc4dfb3eb29 100644 --- a/core/templates/pages/topic-viewer-page/topic-header/topic-header.component.spec.ts +++ b/core/templates/pages/topic-viewer-page/topic-header/topic-header.component.spec.ts @@ -24,24 +24,15 @@ import { } from 'services/i18n-language-code.service'; import {MockTranslatePipe} from 'tests/unit-test-utils'; import {TopicHeaderComponent} from './topic-header.component'; -import {UrlService} from 'services/contextual/url.service'; describe('TopicHeaderComponent', () => { let component: TopicHeaderComponent; let fixture: ComponentFixture; let i18nLanguageCodeService: I18nLanguageCodeService; - let urlService: jasmine.SpyObj; beforeEach(waitForAsync(() => { - urlService = jasmine.createSpyObj('UrlService', [ - 'getClassroomUrlFragmentFromLearnerUrl', - 'getTopicUrlFragmentFromLearnerUrl', - 'getLearnerClassroomUrl', - 'getLearnerTopicStoryUrl', - ]); TestBed.configureTestingModule({ declarations: [TopicHeaderComponent, MockTranslatePipe], - providers: [{provide: UrlService, useValue: urlService}], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); @@ -51,15 +42,6 @@ describe('TopicHeaderComponent', () => { component = fixture.componentInstance; i18nLanguageCodeService = TestBed.inject(I18nLanguageCodeService); - urlService = TestBed.inject(UrlService) as jasmine.SpyObj; - urlService.getClassroomUrlFragmentFromLearnerUrl.and.returnValue('math'); - urlService.getTopicUrlFragmentFromLearnerUrl.and.returnValue( - 'place-values' - ); - urlService.getLearnerClassroomUrl.and.returnValue('/learn/math'); - urlService.getLearnerTopicStoryUrl.and.returnValue( - '/learn/math/place-values/story' - ); spyOn(i18nLanguageCodeService, 'getTopicTranslationKey').and.returnValues( 'topic.title.translation.key', @@ -90,33 +72,28 @@ describe('TopicHeaderComponent', () => { component.topicName = 'Place Values'; component.topicDescription = 'Learn about place values.'; component.classroomName = 'Math'; + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'place-values'; fixture.detectChanges(); }); - it('should have inputs set from parent and fetch classroomUrlFragment internally', () => { + it('should have inputs set from parent', () => { expect(component.topicName).toBe('Place Values'); expect(component.topicDescription).toBe('Learn about place values.'); expect(component.classroomName).toBe('Math'); - expect(urlService.getClassroomUrlFragmentFromLearnerUrl).toHaveBeenCalled(); expect(component.classroomUrlFragment).toBe('math'); + expect(component.topicUrlFragment).toBe('place-values'); }); it('should return /learn/ or /learn', () => { expect(component.getClassroomUrl()).toBe('/learn/math'); - expect(urlService.getLearnerClassroomUrl).toHaveBeenCalledWith(); component.classroomUrlFragment = ''; - urlService.getLearnerClassroomUrl.and.returnValue('/learn'); expect(component.getClassroomUrl()).toBe('/learn'); }); it('should return topic story URL when fragments exist and /learn otherwise', () => { expect(component.getTopicStoryUrl()).toBe('/learn/math/place-values/story'); - expect(urlService.getLearnerTopicStoryUrl).toHaveBeenCalledWith(); - - component.classroomUrlFragment = ''; - urlService.getLearnerTopicStoryUrl.and.returnValue('/learn'); - expect(component.getTopicStoryUrl()).toBe('/learn'); component.classroomUrlFragment = 'math'; component.topicUrlFragment = ''; diff --git a/core/templates/pages/topic-viewer-page/topic-header/topic-header.component.ts b/core/templates/pages/topic-viewer-page/topic-header/topic-header.component.ts index c08570a68b0aa..920641d9e7a0c 100644 --- a/core/templates/pages/topic-viewer-page/topic-header/topic-header.component.ts +++ b/core/templates/pages/topic-viewer-page/topic-header/topic-header.component.ts @@ -17,7 +17,7 @@ */ import {Component, Input, OnInit} from '@angular/core'; -import {UrlService} from 'services/contextual/url.service'; +import {ClassroomDomainConstants} from 'domain/classroom/classroom-domain.constants'; import { I18nLanguageCodeService, TranslationKeyType, @@ -35,17 +35,14 @@ export class TopicHeaderComponent implements OnInit { @Input() topicId!: string; @Input() classroomName!: string | null; @Input() showStudySkillsBreadcrumb: boolean = false; - classroomUrlFragment!: string; - topicUrlFragment!: string; + @Input() classroomUrlFragment: string = ''; + @Input() topicUrlFragment: string = ''; topicNameTranslationKey!: string; topicDescTranslationKey!: string; classroomNameTranslationKey!: string; - constructor( - private i18nLanguageCodeService: I18nLanguageCodeService, - private urlService: UrlService - ) {} + constructor(private i18nLanguageCodeService: I18nLanguageCodeService) {} ngOnInit(): void { this.topicNameTranslationKey = @@ -59,10 +56,6 @@ export class TopicHeaderComponent implements OnInit { TranslationKeyType.DESCRIPTION ); - this.classroomUrlFragment = - this.urlService.getClassroomUrlFragmentFromLearnerUrl(); - this.topicUrlFragment = this.urlService.getTopicUrlFragmentFromLearnerUrl(); - if (this.classroomName) { this.classroomNameTranslationKey = this.i18nLanguageCodeService.getClassroomTranslationKeys( @@ -101,10 +94,20 @@ export class TopicHeaderComponent implements OnInit { } getClassroomUrl(): string { - return this.urlService.getLearnerClassroomUrl(); + return this.classroomUrlFragment + ? `/learn/${this.classroomUrlFragment}` + : '/learn'; } getTopicStoryUrl(): string { - return this.urlService.getLearnerTopicStoryUrl(); + return this.classroomUrlFragment && this.topicUrlFragment + ? ClassroomDomainConstants.TOPIC_VIEWER_STORY_URL_TEMPLATE.replace( + '', + encodeURIComponent(this.classroomUrlFragment) + ).replace( + '', + encodeURIComponent(this.topicUrlFragment) + ) + : '/learn'; } } diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-circle-badge.component.html b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-circle-badge.component.html index 7aa47ce9931e7..4981d02c237a1 100644 --- a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-circle-badge.component.html +++ b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-circle-badge.component.html @@ -1,16 +1,17 @@ - - + {{ label }} diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-circle-badge.component.spec.ts b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-circle-badge.component.spec.ts index 4deda4ced2a1b..64c3df5f90f9b 100644 --- a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-circle-badge.component.spec.ts +++ b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-circle-badge.component.spec.ts @@ -16,20 +16,19 @@ * @fileoverview Unit tests for AdventureCircleBadgeComponent. */ -import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; +import {TestBed, waitForAsync} from '@angular/core/testing'; import {AdventureCircleBadgeComponent} from './adventure-circle-badge.component'; describe('AdventureCircleBadgeComponent', () => { let component: AdventureCircleBadgeComponent; - let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [AdventureCircleBadgeComponent], }).compileComponents(); - fixture = TestBed.createComponent(AdventureCircleBadgeComponent); + const fixture = TestBed.createComponent(AdventureCircleBadgeComponent); component = fixture.componentInstance; })); @@ -44,45 +43,72 @@ describe('AdventureCircleBadgeComponent', () => { expect(component.borderColor).toBe('#7f8c8d'); expect(component.textColor).toBe('#334155'); expect(component.size).toBe('md'); + expect(component.title).toBe(''); + expect(component.getAriaLabel()).toBe(''); + expect(component.getTooltipText()).toBe(''); }); - it('should return default class when size is md', () => { + it('should update circleClass when size changes', () => { component.size = 'md'; - expect(component.circleClass).toBe('adventure-circle-badge'); - }); - it('should return small class when size is sm', () => { component.size = 'sm'; - expect(component.circleClass).toBe( 'adventure-circle-badge adventure-circle-badge--sm' ); }); - it('should update input properties', () => { + it('should use the label for the aria label when present', () => { component.label = '1'; + component.iconName = 'check'; + + expect(component.getAriaLabel()).toBe('1'); + }); + + it('should fall back to the icon name for the aria label when no label is present', () => { + component.label = ''; component.iconName = 'done'; - component.backgroundColor = '#000000'; - component.borderColor = '#111111'; - component.textColor = '#ffffff'; - component.size = 'sm'; - expect(component.label).toBe('1'); - expect(component.iconName).toBe('done'); - expect(component.backgroundColor).toBe('#000000'); - expect(component.borderColor).toBe('#111111'); - expect(component.textColor).toBe('#ffffff'); - expect(component.size).toBe('sm'); + expect(component.getAriaLabel()).toBe('done'); }); - it('should update circleClass when size changes', () => { - component.size = 'md'; - expect(component.circleClass).toBe('adventure-circle-badge'); + it('should use the explicit title for the tooltip', () => { + component.title = 'Go to lesson 1'; + component.label = '1'; + component.iconName = 'check'; - component.size = 'sm'; - expect(component.circleClass).toBe( - 'adventure-circle-badge adventure-circle-badge--sm' - ); + expect(component.getTooltipText()).toBe('Go to lesson 1'); + }); + + it('should fall back to the label for the tooltip when no title is provided', () => { + component.title = ''; + component.label = '1'; + component.iconName = 'check'; + + expect(component.getTooltipText()).toBe('1'); + }); + + it('should fall back to the icon name for the tooltip when no title or label is provided', () => { + component.title = ''; + component.label = ''; + component.iconName = 'done'; + + expect(component.getTooltipText()).toBe('done'); + }); + + it('should report the icon as shown when an icon name is provided', () => { + component.iconName = 'check'; + component.label = '1'; + + expect(component.hasIcon).toBeTrue(); + expect(component.getAriaLabel()).toBe('1'); + }); + + it('should report the label as shown when no icon name is provided', () => { + component.iconName = ''; + component.label = '1'; + + expect(component.hasIcon).toBeFalse(); + expect(component.getAriaLabel()).toBe('1'); }); }); diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-circle-badge.component.ts b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-circle-badge.component.ts index 04e76574a4f77..9dee532edf5f6 100644 --- a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-circle-badge.component.ts +++ b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-circle-badge.component.ts @@ -17,7 +17,6 @@ */ import {Component, Input} from '@angular/core'; - import './adventure-circle-badge.component.css'; @Component({ @@ -32,10 +31,23 @@ export class AdventureCircleBadgeComponent { @Input() borderColor: string = '#7f8c8d'; @Input() textColor: string = '#334155'; @Input() size: 'sm' | 'md' = 'md'; + @Input() title: string = ''; get circleClass(): string { return this.size === 'sm' ? 'adventure-circle-badge adventure-circle-badge--sm' : 'adventure-circle-badge'; } + + get hasIcon(): boolean { + return this.iconName !== ''; + } + + getAriaLabel(): string { + return this.label || this.iconName; + } + + getTooltipText(): string { + return this.title || this.label || this.iconName; + } } diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-end-test-card.component.html b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-end-test-card.component.html index 493d85b8c19da..a70f486ed7672 100644 --- a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-end-test-card.component.html +++ b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-end-test-card.component.html @@ -11,11 +11,11 @@
+ [backgroundColor]="isPracticeCompleted ? '#e7f6e9' : '#fff'" + [borderColor]="isPracticeCompleted ? '#40a84f' : cardAccentColor" + [textColor]="isPracticeCompleted ? '#40a84f' : cardAccentColor">
diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-end-test-card.component.ts b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-end-test-card.component.ts index 387b819a28304..aea8af6a10255 100644 --- a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-end-test-card.component.ts +++ b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-end-test-card.component.ts @@ -37,6 +37,7 @@ export class AdventureEndTestCardComponent implements OnInit { @Input() practiceUrl: string = ''; @Input() cardBackgroundColor: string = '#ecf7f6'; @Input() cardAccentColor: string = '#0b776d'; + @Input() isPracticeCompleted: boolean = false; resolvedThumbnailUrl: string = ''; diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-mastered-modal.component.css b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-mastered-modal.component.css new file mode 100644 index 0000000000000..d0059040b0f0d --- /dev/null +++ b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-mastered-modal.component.css @@ -0,0 +1,200 @@ +.adventure-mastered-modal-backdrop { + align-items: center; + background: rgba(16, 24, 40, 0.52); + bottom: 0; + display: flex; + justify-content: center; + left: 0; + padding: 16px; + position: fixed; + right: 0; + top: 0; + z-index: 1210; +} + +.adventure-mastered-modal { + background: #fff; + border: 1px solid #edf1f7; + border-radius: 16px; + max-height: calc(100vh - 32px); + max-width: 620px; + overflow-x: hidden; + overflow-y: auto; + padding: 22px 28px 24px; + position: relative; + text-align: center; + width: min(620px, calc(100vw - 32px)); +} + +.adventure-mastered-confetti { + height: 120px; + left: 0; + pointer-events: none; + position: absolute; + right: 0; + top: 0; +} + +.adventure-mastered-confetti-piece { + border-radius: 999px; + display: inline-block; + position: absolute; +} + +.adventure-mastered-confetti-piece.piece-1 { + background: #ffb703; + height: 16px; + left: 10%; + top: 30px; + width: 6px; +} + +.adventure-mastered-confetti-piece.piece-2 { + background: #3b82f6; + height: 6px; + left: 22%; + top: 56px; + width: 16px; +} + +.adventure-mastered-confetti-piece.piece-3 { + background: #22c55e; + height: 10px; + left: 36%; + top: 22px; + width: 10px; +} + +.adventure-mastered-confetti-piece.piece-4 { + background: #f97316; + height: 6px; + left: 47%; + top: 48px; + width: 14px; +} + +.adventure-mastered-confetti-piece.piece-5 { + background: #a855f7; + height: 12px; + left: 62%; + top: 30px; + width: 8px; +} + +.adventure-mastered-confetti-piece.piece-6 { + background: #06b6d4; + height: 8px; + left: 76%; + top: 54px; + width: 8px; +} + +.adventure-mastered-confetti-piece.piece-7 { + background: #ec4899; + height: 6px; + left: 88%; + top: 38px; + width: 14px; +} + +.adventure-mastered-confetti-piece.piece-8 { + background: #ffb703; + height: 12px; + left: 16%; + top: 78px; + width: 12px; +} + +.adventure-mastered-confetti-piece.piece-9 { + background: #7c3aed; + height: 12px; + left: 70%; + top: 84px; + width: 12px; +} + +.adventure-mastered-confetti-piece.piece-10 { + background: #14b8a6; + height: 10px; + left: 83%; + top: 72px; + width: 10px; +} + +.adventure-mastered-trophy { + display: block; + height: 150px; + margin: 52px auto 14px; + width: 150px; +} + +.adventure-mastered-title { + color: #111827; + font-family: Roboto, Arial, sans-serif; + font-size: 44px; + font-weight: 700; + line-height: 1.18; + margin: 0; +} + +.adventure-mastered-message { + color: #374151; + font-family: Roboto, Arial, sans-serif; + font-size: 20px; + line-height: 1.45; + margin: 14px auto 22px; + max-width: 410px; +} + +.adventure-mastered-continue { + background: #00645c; + border: 2px solid #00645c; + border-radius: 6px; + color: #fff; + font-family: Roboto, Arial, sans-serif; + font-size: 20px; + font-weight: 700; + min-height: 56px; + min-width: 300px; + padding: 10px 22px; +} + +.adventure-mastered-continue:focus-visible { + outline: 2px solid #00645c; + outline-offset: 2px; +} + +@media (max-width: 768px) { + .adventure-mastered-modal { + border-radius: 12px; + padding: 16px 16px 18px; + width: min(560px, calc(100vw - 24px)); + } + + .adventure-mastered-confetti { + height: 90px; + } + + .adventure-mastered-trophy { + height: 112px; + margin: 36px auto 12px; + width: 112px; + } + + .adventure-mastered-title { + font-size: 30px; + } + + .adventure-mastered-message { + font-size: 16px; + margin: 10px auto 16px; + max-width: 100%; + } + + .adventure-mastered-continue { + font-size: 16px; + min-height: 48px; + min-width: 0; + width: 100%; + } +} diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-mastered-modal.component.html b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-mastered-modal.component.html new file mode 100644 index 0000000000000..63ac68f2ae8ce --- /dev/null +++ b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-mastered-modal.component.html @@ -0,0 +1,39 @@ + diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-mastered-modal.component.spec.ts b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-mastered-modal.component.spec.ts new file mode 100644 index 0000000000000..9e59bd7a97ead --- /dev/null +++ b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-mastered-modal.component.spec.ts @@ -0,0 +1,130 @@ +// 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 Unit tests for AdventureMasteredModalComponent. + */ + +import {TestBed, waitForAsync} from '@angular/core/testing'; +import { + MAT_BOTTOM_SHEET_DATA, + MatBottomSheetRef, +} from '@angular/material/bottom-sheet'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; + +import {MockTranslatePipe} from 'tests/unit-test-utils'; + +import {AdventureMasteredModalComponent} from './adventure-mastered-modal.component'; + +describe('AdventureMasteredModalComponent', () => { + let component: AdventureMasteredModalComponent; + let ngbActiveModal: jasmine.SpyObj; + + beforeEach(waitForAsync(() => { + ngbActiveModal = jasmine.createSpyObj('NgbActiveModal', [ + 'close', + 'dismiss', + ]); + + TestBed.configureTestingModule({ + declarations: [AdventureMasteredModalComponent, MockTranslatePipe], + providers: [{provide: NgbActiveModal, useValue: ngbActiveModal}], + }).compileComponents(); + })); + + beforeEach(() => { + const fixture = TestBed.createComponent(AdventureMasteredModalComponent); + component = fixture.componentInstance; + component.title = 'Adventure 1 mastered'; + component.message = 'You have completed all lessons in this adventure'; + }); + + it('should render the title, message and continue button', () => { + const fixture = TestBed.createComponent(AdventureMasteredModalComponent); + fixture.componentInstance.title = 'Adventure 1 mastered'; + fixture.componentInstance.message = + 'You have completed all lessons in this adventure'; + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.adventure-mastered-title') + ).toBeTruthy(); + expect( + fixture.nativeElement.querySelector('.adventure-mastered-message') + ).toBeTruthy(); + expect( + fixture.nativeElement.querySelector('.adventure-mastered-continue') + ).toBeTruthy(); + }); + + it('should close the modal when confirm is called', () => { + component.confirm(); + + expect(ngbActiveModal.close).toHaveBeenCalled(); + }); + + it('should dismiss the modal when cancel is called', () => { + component.cancel(); + + expect(ngbActiveModal.dismiss).toHaveBeenCalledWith('cancel'); + }); + + describe('when opened as MatBottomSheet', () => { + let bottomSheetRef: jasmine.SpyObj< + MatBottomSheetRef + >; + + beforeEach(waitForAsync(() => { + bottomSheetRef = jasmine.createSpyObj('MatBottomSheetRef', ['dismiss']); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + declarations: [AdventureMasteredModalComponent, MockTranslatePipe], + providers: [ + {provide: NgbActiveModal, useValue: ngbActiveModal}, + {provide: MatBottomSheetRef, useValue: bottomSheetRef}, + { + provide: MAT_BOTTOM_SHEET_DATA, + useValue: { + title: 'Adventure 1 mastered', + message: 'You have completed all lessons', + }, + }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + const fixture = TestBed.createComponent(AdventureMasteredModalComponent); + component = fixture.componentInstance; + }); + + it('should read data from MAT_BOTTOM_SHEET_DATA', () => { + expect(component.title).toBe('Adventure 1 mastered'); + expect(component.message).toBe('You have completed all lessons'); + }); + + it('should dismiss the bottom sheet on confirm', () => { + component.confirm(); + + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith('confirm'); + }); + + it('should dismiss the bottom sheet on cancel', () => { + component.cancel(); + + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith('cancel'); + }); + }); +}); diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-mastered-modal.component.ts b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-mastered-modal.component.ts new file mode 100644 index 0000000000000..fd0f593888426 --- /dev/null +++ b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-mastered-modal.component.ts @@ -0,0 +1,71 @@ +// 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 Modal that celebrates the learner after they complete all + * lessons of an adventure. + */ + +import {Component, Inject, Input, Optional} from '@angular/core'; +import { + MAT_BOTTOM_SHEET_DATA, + MatBottomSheetRef, +} from '@angular/material/bottom-sheet'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; + +import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; + +import './adventure-mastered-modal.component.css'; + +@Component({ + selector: 'adventure-mastered-modal', + templateUrl: './adventure-mastered-modal.component.html', + styleUrls: ['./adventure-mastered-modal.component.css'], +}) +export class AdventureMasteredModalComponent extends ConfirmOrCancelModal { + @Input() title!: string; + @Input() message!: string; + + protected bottomSheetRef: MatBottomSheetRef | undefined; + constructor( + private ngbActiveModal: NgbActiveModal, + @Optional() bottomSheetRef: MatBottomSheetRef | null, + @Optional() + @Inject(MAT_BOTTOM_SHEET_DATA) + private data: {title: string; message: string} | null + ) { + super(ngbActiveModal); + this.bottomSheetRef = bottomSheetRef ?? undefined; + if (this.data) { + this.title = this.data.title; + this.message = this.data.message; + } + } + + confirm(): void { + if (this.bottomSheetRef) { + this.bottomSheetRef.dismiss('confirm'); + } else { + super.confirm(); + } + } + + cancel(): void { + if (this.bottomSheetRef) { + this.bottomSheetRef.dismiss('cancel'); + } else { + super.cancel(); + } + } +} diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-navigation.component.css b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-navigation.component.css index 135cbbb0d26df..021b3cb28fe09 100644 --- a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-navigation.component.css +++ b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-navigation.component.css @@ -1,7 +1,22 @@ .adventure-navigation-container { + background: #fff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06); display: flex; flex-direction: column; gap: 0; + margin: 0 -24px; + opacity: 1; + padding: 0 24px; + position: -webkit-sticky; + position: sticky; + top: 56px; + z-index: 30; +} + +@media (max-width: 800px) { + .adventure-navigation-container { + top: 56px; + } } .adventure-navigation-scroll-area { @@ -21,8 +36,7 @@ min-width: 0; overflow-x: auto; padding-bottom: 4px; - /* Hide native scrollbar — arrows replace it */ - scrollbar-width: none; + position: relative; } .adventure-navigation-wrapper--centered { @@ -33,6 +47,28 @@ display: none; } +.adventure-navigation-wrapper--overflow::before, +.adventure-navigation-wrapper--overflow::after { + bottom: 4px; + content: ''; + display: none; + pointer-events: none; + position: absolute; + top: 0; + width: 22px; + z-index: 3; +} + +.adventure-navigation-wrapper--overflow::before { + background: linear-gradient(to right, #fff 0%, rgba(255, 255, 255, 0) 100%); + left: 0; +} + +.adventure-navigation-wrapper--overflow::after { + background: linear-gradient(to left, #fff 0%, rgba(255, 255, 255, 0) 100%); + right: 0; +} + .adventure-navigation-arrow { align-items: center; background: transparent; @@ -120,6 +156,18 @@ } @media (max-width: 768px) { + .adventure-navigation-container { + margin: 0; + margin-top: 0; + padding: 0 14px; + padding-top: 0; + } + + .adventure-navigation-wrapper--overflow::before, + .adventure-navigation-wrapper--overflow::after { + display: block; + } + .adventure-navigation-wrapper { gap: 8px; margin: 6px 0 4px; @@ -151,6 +199,18 @@ } @media (max-width: 450px) { + .adventure-navigation-container { + margin: 0; + margin-top: 0; + padding: 0 12px; + padding-top: 0; + } + + .adventure-navigation-wrapper--overflow::before, + .adventure-navigation-wrapper--overflow::after { + width: 18px; + } + .adventure-navigation-wrapper { gap: 6px; margin: 4px 0 2px; diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-navigation.component.html b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-navigation.component.html index 6776852a6cc61..d57406db6a446 100644 --- a/core/templates/pages/topic-viewer-page/topic-story-section/adventure-navigation.component.html +++ b/core/templates/pages/topic-viewer-page/topic-story-section/adventure-navigation.component.html @@ -1,4 +1,6 @@ -
+
+ + + +

+ {{ 'I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_TITLE' | translate: {adventureLabel: adventureLabel} }} +

+

+ {{ confirmationMessage }} +

+ + + +
+ + +
+ diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/arc-skip-confirmation-modal.component.spec.ts b/core/templates/pages/topic-viewer-page/topic-story-section/arc-skip-confirmation-modal.component.spec.ts new file mode 100644 index 0000000000000..2877692025d5e --- /dev/null +++ b/core/templates/pages/topic-viewer-page/topic-story-section/arc-skip-confirmation-modal.component.spec.ts @@ -0,0 +1,137 @@ +// 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 Unit tests for ArcSkipConfirmationModalComponent. + */ + +import {TestBed, waitForAsync} from '@angular/core/testing'; +import { + MAT_BOTTOM_SHEET_DATA, + MatBottomSheetRef, +} from '@angular/material/bottom-sheet'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; + +import {MockTranslatePipe} from 'tests/unit-test-utils'; + +import {ArcSkipConfirmationModalComponent} from './arc-skip-confirmation-modal.component'; + +describe('ArcSkipConfirmationModalComponent', () => { + let component: ArcSkipConfirmationModalComponent; + let ngbActiveModal: jasmine.SpyObj; + + beforeEach(waitForAsync(() => { + ngbActiveModal = jasmine.createSpyObj('NgbActiveModal', [ + 'close', + 'dismiss', + ]); + + TestBed.configureTestingModule({ + declarations: [ArcSkipConfirmationModalComponent, MockTranslatePipe], + providers: [{provide: NgbActiveModal, useValue: ngbActiveModal}], + }).compileComponents(); + })); + + beforeEach(() => { + const fixture = TestBed.createComponent(ArcSkipConfirmationModalComponent); + component = fixture.componentInstance; + component.adventureLabel = 'Adventure 2'; + component.confirmationMessage = 'Adventure 1 will be skipped'; + }); + + it('should dismiss the modal when cancel is called', () => { + component.cancel(); + + expect(ngbActiveModal.dismiss).toHaveBeenCalledWith('cancel'); + }); + + it('should close the modal when confirm is called', () => { + component.confirm(); + + expect(ngbActiveModal.close).toHaveBeenCalled(); + }); + + it('should render the title, message, and action buttons', () => { + const fixture = TestBed.createComponent(ArcSkipConfirmationModalComponent); + fixture.componentInstance.adventureLabel = 'Adventure 2'; + fixture.componentInstance.confirmationMessage = + 'Adventure 1 will be skipped'; + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.arc-skip-confirmation-title') + ).toBeTruthy(); + expect( + fixture.nativeElement + .querySelector('.arc-skip-confirmation-message') + .textContent.trim() + ).toBe('Adventure 1 will be skipped'); + expect( + fixture.nativeElement.querySelector('.arc-skip-confirmation-cancel') + ).toBeTruthy(); + expect( + fixture.nativeElement.querySelector('.arc-skip-confirmation-proceed') + ).toBeTruthy(); + }); + + describe('when opened as MatBottomSheet', () => { + let bottomSheetRef: jasmine.SpyObj< + MatBottomSheetRef + >; + + beforeEach(waitForAsync(() => { + bottomSheetRef = jasmine.createSpyObj('MatBottomSheetRef', ['dismiss']); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + declarations: [ArcSkipConfirmationModalComponent, MockTranslatePipe], + providers: [ + {provide: NgbActiveModal, useValue: ngbActiveModal}, + {provide: MatBottomSheetRef, useValue: bottomSheetRef}, + { + provide: MAT_BOTTOM_SHEET_DATA, + useValue: { + adventureLabel: 'Adventure 2', + confirmationMessage: 'Adventure 1 will be skipped', + }, + }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + const fixture = TestBed.createComponent( + ArcSkipConfirmationModalComponent + ); + component = fixture.componentInstance; + }); + + it('should read data from MAT_BOTTOM_SHEET_DATA', () => { + expect(component.adventureLabel).toBe('Adventure 2'); + expect(component.confirmationMessage).toBe('Adventure 1 will be skipped'); + }); + + it('should dismiss the bottom sheet on confirm', () => { + component.confirm(); + + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith('confirm'); + }); + + it('should dismiss the bottom sheet on cancel', () => { + component.cancel(); + + expect(bottomSheetRef.dismiss).toHaveBeenCalledWith('cancel'); + }); + }); +}); diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/arc-skip-confirmation-modal.component.ts b/core/templates/pages/topic-viewer-page/topic-story-section/arc-skip-confirmation-modal.component.ts new file mode 100644 index 0000000000000..72e5fdcbafd88 --- /dev/null +++ b/core/templates/pages/topic-viewer-page/topic-story-section/arc-skip-confirmation-modal.component.ts @@ -0,0 +1,71 @@ +// 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 Modal that asks the learner to confirm skipping an adventure + * when they select a lesson in a later adventure from the navigation. + */ + +import {Component, Inject, Input, Optional} from '@angular/core'; +import { + MAT_BOTTOM_SHEET_DATA, + MatBottomSheetRef, +} from '@angular/material/bottom-sheet'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; + +import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; + +import './arc-skip-confirmation-modal.component.css'; + +@Component({ + selector: 'arc-skip-confirmation-modal', + templateUrl: './arc-skip-confirmation-modal.component.html', + styleUrls: ['./arc-skip-confirmation-modal.component.css'], +}) +export class ArcSkipConfirmationModalComponent extends ConfirmOrCancelModal { + @Input() adventureLabel!: string; + @Input() confirmationMessage!: string; + + protected bottomSheetRef: MatBottomSheetRef | undefined; + constructor( + private ngbActiveModal: NgbActiveModal, + @Optional() bottomSheetRef: MatBottomSheetRef | null, + @Optional() + @Inject(MAT_BOTTOM_SHEET_DATA) + private data: {adventureLabel: string; confirmationMessage: string} | null + ) { + super(ngbActiveModal); + this.bottomSheetRef = bottomSheetRef ?? undefined; + if (this.data) { + this.adventureLabel = this.data.adventureLabel; + this.confirmationMessage = this.data.confirmationMessage; + } + } + + confirm(): void { + if (this.bottomSheetRef) { + this.bottomSheetRef.dismiss('confirm'); + } else { + super.confirm(); + } + } + + cancel(): void { + if (this.bottomSheetRef) { + this.bottomSheetRef.dismiss('cancel'); + } else { + super.cancel(); + } + } +} diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.css b/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.css index 1d843633eb1e6..6f3e4fcf62807 100644 --- a/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.css +++ b/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.css @@ -27,6 +27,14 @@ min-width: 0; } +.mastery-challenge-action-wrapper { + align-items: flex-end; + display: inline-flex; + flex: 0 0 auto; + flex-direction: column; + position: relative; +} + .mastery-challenge-title { color: #373a3e; font-family: Roboto, Arial, sans-serif; @@ -51,9 +59,9 @@ background: #00645c; border: none; border-radius: 6px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.14); color: #fff; display: inline-flex; - flex: 0 0 auto; font-family: Roboto, Arial, sans-serif; font-size: 16px; font-weight: 700; @@ -64,6 +72,27 @@ padding: 0 24px; } +.mastery-challenge-button:focus-visible { + box-shadow: 0 0 0 3px rgba(0, 100, 92, 0.2); + outline: none; +} + +.mastery-challenge-button:hover:not(:disabled) { + filter: brightness(0.96); +} + +.mastery-challenge-button.mastery-challenge-button-locked { + background: #fff; + border: 2px solid #78a8a2; + box-shadow: none; + color: #3f7f79; +} + +.mastery-challenge-button.mastery-challenge-button-locked:hover:not(:disabled) { + background: #f7fbfa; + filter: none; +} + .mastery-challenge-button-icon { align-items: center; background: #fbbc04; @@ -78,19 +107,107 @@ width: 32px; } +.mastery-challenge-button-icon .material-icons { + font-size: 18px; +} + +.mastery-challenge-button-icon.mastery-challenge-button-icon-locked { + background: #eaf3f2; + color: #3f7f79; +} + +.mastery-challenge-helper-tooltip { + align-items: center; + background: #fff; + border: 1px solid #e2e5e9; + border-radius: 12px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); + display: flex; + gap: 12px; + margin-top: 10px; + max-width: min(440px, calc(100vw - 48px)); + min-width: 380px; + padding: 16px 18px; + position: absolute; + right: 0; + top: 100%; + z-index: 1000; +} + +.mastery-challenge-helper-tooltip::before { + border-bottom: 12px solid #e2e5e9; + border-left: 12px solid transparent; + border-right: 12px solid transparent; + content: ''; + position: absolute; + right: 56px; + top: -12px; +} + +.mastery-challenge-helper-tooltip::after { + border-bottom: 11px solid #fff; + border-left: 11px solid transparent; + border-right: 11px solid transparent; + content: ''; + position: absolute; + right: 57px; + top: -10px; +} + +.mastery-challenge-helper-icon { + align-items: center; + background: #fff5df; + border-radius: 50%; + color: #f4ab17; + display: inline-flex; + flex: 0 0 72px; + height: 72px; + justify-content: center; + width: 72px; +} + +.mastery-challenge-helper-icon .material-icons { + font-size: 30px; +} + +.mastery-challenge-helper-content { + min-width: 0; +} + +.mastery-challenge-helper-title { + color: #2f3b52; + font-family: Roboto, Arial, sans-serif; + font-size: 18px; + font-weight: 700; + line-height: 24px; + margin: 0 0 6px; +} + +.mastery-challenge-helper-description { + color: #2f3b52; + font-family: Roboto, Arial, sans-serif; + font-size: 14px; + line-height: 20px; + margin: 0; +} + +.mastery-challenge-button-icon.mastery-challenge-button-icon-locked .material-icons { + font-size: 17px; +} + @media (max-width: 768px) { .mastery-challenge-card { - gap: 16px; - padding: 16px 18px; + gap: 14px; + padding: 14px 16px; } .mastery-challenge-icon { - flex: 0 0 80px; + flex: 0 0 72px; } .mastery-challenge-icon-image { - height: 80px; - width: 80px; + height: 72px; + width: 72px; } .mastery-challenge-content { @@ -99,53 +216,143 @@ } .mastery-challenge-title { - font-size: 24px; - line-height: 24px; + font-size: 20px; + line-height: 22px; margin: 0 0 6px; } .mastery-challenge-description { - font-size: 17px; - line-height: 24px; + font-size: 15px; + line-height: 21px; } .mastery-challenge-button { font-size: 14px; - height: 40px; + height: 38px; min-width: 140px; - padding: 0 16px; + padding: 0 14px; } .mastery-challenge-button-icon { - font-size: 16px; height: 26px; width: 26px; } + + .mastery-challenge-button-icon .material-icons { + font-size: 16px; + } + + .mastery-challenge-helper-tooltip { + left: 50%; + min-width: min(380px, calc(100vw - 32px)); + right: auto; + transform: translateX(-50%); + } + + .mastery-challenge-helper-tooltip::before { + left: 50%; + right: auto; + transform: translateX(-50%); + } + + .mastery-challenge-helper-tooltip::after { + left: 50%; + right: auto; + transform: translateX(-50%); + } + + .mastery-challenge-helper-title { + font-size: 17px; + line-height: 22px; + } + + .mastery-challenge-helper-description { + font-size: 14px; + line-height: 20px; + } } @media (max-width: 450px) { .mastery-challenge-card { flex-direction: column; - gap: 12px; - padding: 16px; + gap: 10px; + margin-top: 14px; + padding: 12px; } .mastery-challenge-icon { flex: none; } + .mastery-challenge-icon-image { + height: 64px; + width: 64px; + } + .mastery-challenge-title { - font-size: 18px; + font-size: 16px; + line-height: 20px; text-align: center; } .mastery-challenge-description { - font-size: 14px; + font-size: 13px; + line-height: 19px; text-align: center; } .mastery-challenge-button { - height: 44px; + font-size: 15px; + height: 40px; + min-width: 0; + padding: 0 12px; width: 100%; } + + .mastery-challenge-button-icon { + height: 24px; + width: 24px; + } + + .mastery-challenge-button-icon .material-icons { + font-size: 14px; + } + + .mastery-challenge-action-wrapper { + width: 100%; + } + + .mastery-challenge-helper-tooltip { + align-items: center; + flex-direction: column; + gap: 10px; + min-width: calc(100vw - 44px); + padding: 14px; + text-align: center; + } + + .mastery-challenge-helper-icon { + flex: 0 0 64px; + height: 64px; + width: 64px; + } + + .mastery-challenge-helper-icon .material-icons { + font-size: 30px; + } + + .mastery-challenge-helper-title { + font-size: 16px; + line-height: 22px; + margin-bottom: 8px; + } + + .mastery-challenge-helper-description { + font-size: 13px; + line-height: 18px; + } + + .mastery-challenge-button-icon.mastery-challenge-button-icon-locked .material-icons { + font-size: 13px; + } } diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.html b/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.html index 974ee2e421124..9a8d7e533bec6 100644 --- a/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.html +++ b/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.html @@ -9,11 +9,35 @@

{{ 'I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_TITLE' | translate }}

{{ 'I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_DESCRIPTION' | translate }}

- +
+ + +
+ +
+

+ {{ 'I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_TOOLTIP_TITLE' | translate }} +

+

+ {{ 'I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_TOOLTIP_DESCRIPTION' | translate }} +

+
+
+
diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.spec.ts b/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.spec.ts index 0bb421b913495..83a428bc2cbda 100644 --- a/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.spec.ts +++ b/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.spec.ts @@ -16,23 +16,35 @@ * @fileoverview Unit tests for MasteryChallengeCardComponent. */ -import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; +import {fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing'; import {MockTranslateModule} from 'tests/unit-test-utils'; import {MasteryChallengeCardComponent} from './mastery-challenge-card.component'; import {WindowRef} from 'services/contextual/window-ref.service'; +const _setTimeout = setTimeout; +const _clearTimeout = clearTimeout; + class MockWindowRef { nativeWindow = { location: { assign: (url: string) => {}, }, + setTimeout: jasmine + .createSpy('setTimeout') + .and.callFake((callback: () => void, timeout: number): number => { + return _setTimeout(callback, timeout) as unknown as number; + }), + clearTimeout: jasmine + .createSpy('clearTimeout') + .and.callFake((timeoutId: number): void => { + _clearTimeout(timeoutId); + }), }; } describe('MasteryChallengeCardComponent', () => { let component: MasteryChallengeCardComponent; - let fixture: ComponentFixture; let windowRef: WindowRef; beforeEach(waitForAsync(() => { @@ -47,7 +59,7 @@ describe('MasteryChallengeCardComponent', () => { ], }).compileComponents(); - fixture = TestBed.createComponent(MasteryChallengeCardComponent); + const fixture = TestBed.createComponent(MasteryChallengeCardComponent); component = fixture.componentInstance; windowRef = TestBed.inject(WindowRef); })); @@ -59,8 +71,9 @@ describe('MasteryChallengeCardComponent', () => { it('should navigate when action URL is provided', () => { spyOn(windowRef.nativeWindow.location, 'assign'); component.actionUrl = '/practice/session/1'; + component.isUnlocked = true; - component.navigateToAction(); + component.onChallengeButtonClick(); expect(windowRef.nativeWindow.location.assign).toHaveBeenCalledWith( '/practice/session/1' @@ -70,9 +83,82 @@ describe('MasteryChallengeCardComponent', () => { it('should not navigate when action URL is empty', () => { spyOn(windowRef.nativeWindow.location, 'assign'); component.actionUrl = ''; + component.isUnlocked = true; - component.navigateToAction(); + component.onChallengeButtonClick(); expect(windowRef.nativeWindow.location.assign).not.toHaveBeenCalled(); }); + + it('should not navigate when the default action URL placeholder is used', () => { + spyOn(windowRef.nativeWindow.location, 'assign'); + component.actionUrl = '#'; + component.isUnlocked = true; + + component.onChallengeButtonClick(); + + expect(windowRef.nativeWindow.location.assign).not.toHaveBeenCalled(); + }); + + it('should report that the default placeholder is not an action URL', () => { + expect(component.actionUrl).toBe('#'); + expect(component.hasActionUrl()).toBeFalsy(); + component.actionUrl = ''; + expect(component.hasActionUrl()).toBeFalsy(); + component.actionUrl = '/practice/session/1'; + expect(component.hasActionUrl()).toBeTruthy(); + }); + + it('should report the action button as disabled for an unlocked placeholder URL', () => { + component.isUnlocked = true; + component.actionUrl = '#'; + + expect(component.isActionDisabled()).toBeTruthy(); + + component.actionUrl = '/practice/session/1'; + expect(component.isActionDisabled()).toBeFalsy(); + }); + + it('should show helper tooltip for 5 seconds for a locked challenge', fakeAsync(() => { + spyOn(windowRef.nativeWindow.location, 'assign'); + component.actionUrl = '/practice/session/1'; + component.isUnlocked = false; + + component.onChallengeButtonClick(); + + expect(component.showLockedTooltip).toBeTruthy(); + expect(windowRef.nativeWindow.location.assign).not.toHaveBeenCalled(); + + tick(4999); + expect(component.showLockedTooltip).toBeTruthy(); + + tick(1); + expect(component.showLockedTooltip).toBeFalsy(); + })); + + it('should reset helper tooltip timer when clicked again while locked', fakeAsync(() => { + component.isUnlocked = false; + + component.onChallengeButtonClick(); + tick(3000); + + component.onChallengeButtonClick(); + tick(3000); + expect(component.showLockedTooltip).toBeTruthy(); + + tick(2000); + expect(component.showLockedTooltip).toBeFalsy(); + })); + + it('should keep tooltip visible after destroy clears the timer', fakeAsync(() => { + component.isUnlocked = false; + + component.onChallengeButtonClick(); + expect(component.showLockedTooltip).toBeTruthy(); + + component.ngOnDestroy(); + tick(5000); + + expect(component.showLockedTooltip).toBeTruthy(); + })); }); diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.ts b/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.ts index 2e21cfaf14cf3..2ce9a4055c666 100644 --- a/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.ts +++ b/core/templates/pages/topic-viewer-page/topic-story-section/mastery-challenge-card.component.ts @@ -16,7 +16,7 @@ * @fileoverview Mastery challenge card displayed at the end of a story section. */ -import {Component, Input} from '@angular/core'; +import {Component, Input, OnDestroy} from '@angular/core'; import {WindowRef} from 'services/contextual/window-ref.service'; import './mastery-challenge-card.component.css'; @@ -26,14 +26,55 @@ import './mastery-challenge-card.component.css'; templateUrl: './mastery-challenge-card.component.html', styleUrls: ['./mastery-challenge-card.component.css'], }) -export class MasteryChallengeCardComponent { +export class MasteryChallengeCardComponent implements OnDestroy { @Input() actionUrl: string = '#'; + @Input() isUnlocked: boolean = true; + + showLockedTooltip: boolean = false; + private helperTooltipTimeoutId: number | null = null; constructor(private windowRef: WindowRef) {} + ngOnDestroy(): void { + this.clearHelperTooltipTimeout(); + } + + onChallengeButtonClick(): void { + if (!this.isUnlocked) { + this.showHelperTooltip(); + return; + } + + this.navigateToAction(); + } + navigateToAction(): void { - if (this.actionUrl) { + if (this.hasActionUrl()) { this.windowRef.nativeWindow.location.assign(this.actionUrl); } } + + hasActionUrl(): boolean { + return this.actionUrl !== '' && this.actionUrl !== '#'; + } + + isActionDisabled(): boolean { + return this.isUnlocked && !this.hasActionUrl(); + } + + private showHelperTooltip(): void { + this.showLockedTooltip = true; + this.clearHelperTooltipTimeout(); + this.helperTooltipTimeoutId = this.windowRef.nativeWindow.setTimeout(() => { + this.showLockedTooltip = false; + this.helperTooltipTimeoutId = null; + }, 5000); + } + + private clearHelperTooltipTimeout(): void { + if (this.helperTooltipTimeoutId !== null) { + this.windowRef.nativeWindow.clearTimeout(this.helperTooltipTimeoutId); + this.helperTooltipTimeoutId = null; + } + } } diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/topic-lesson-card/topic-lesson-card.component.css b/core/templates/pages/topic-viewer-page/topic-story-section/topic-lesson-card/topic-lesson-card.component.css index 16bb07c3e05a5..3010310fb214d 100644 --- a/core/templates/pages/topic-viewer-page/topic-story-section/topic-lesson-card/topic-lesson-card.component.css +++ b/core/templates/pages/topic-viewer-page/topic-story-section/topic-lesson-card/topic-lesson-card.component.css @@ -6,15 +6,30 @@ border-radius: 8px; box-shadow: none; display: flex; + height: auto; max-width: 912px; min-height: 248px; + overflow: visible; + transition: border-color 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease; width: 100%; } .topic-lesson-card.collapsed { border: 1px solid #d1d5db; - border-radius: 0; + border-radius: 8px; + box-shadow: none; + height: auto; min-height: 0; + overflow: visible; +} + +.topic-lesson-card.completed-lesson { + border-color: #cde8d4; +} + +.topic-lesson-card.completed-collapsed { + background: #fff; + border-color: #c8dfcf; } .topic-lesson-card.coming-soon-section-card { @@ -50,13 +65,17 @@ gap: 12px; min-width: 0; padding: 20px 22px 14px; + transition: padding 0.22s ease, gap 0.22s ease; } .topic-lesson-card-content.collapsed { align-items: center; + border: none; + box-shadow: none; flex-direction: row; gap: 12px; - padding: 14px 18px; + height: auto; + padding: 14px 16px; } .topic-lesson-card.coming-soon-section-card .topic-lesson-card-content, @@ -89,6 +108,11 @@ width: 100%; } +.topic-lesson-card.completed-collapsed .topic-lesson-card-title-row.collapsed { + align-items: center; + gap: 12px; +} + .topic-lesson-card-header { display: flex; flex: 1; @@ -104,6 +128,12 @@ gap: 8px; } +.topic-lesson-card.completed-collapsed .topic-lesson-card-title-with-labels { + align-items: flex-start; + flex-direction: column; + gap: 4px; +} + .topic-lesson-card-new-label { background: #1f7a42; border-radius: 999px; @@ -115,6 +145,35 @@ padding: 3px 10px; } +.topic-lesson-card-completed-label { + align-items: center; + background: #ebf8ef; + border-radius: 999px; + color: #1c7a49; + display: inline-flex; + font-family: Roboto, Arial, sans-serif; + font-size: 11px; + font-weight: 700; + gap: 4px; + letter-spacing: 0.2px; + padding: 3px 10px; + text-transform: uppercase; +} + +.topic-lesson-card-completed-label.topic-lesson-card-completed-label-compact { + background: transparent; + color: #5f6470; + font-size: 15px; + font-weight: 500; + letter-spacing: 0; + padding: 0; + text-transform: none; +} + +.topic-lesson-card-completed-label .material-icons { + font-size: 14px; +} + .topic-lesson-card-coming-soon-label { background: #d6c9ff; border-radius: 999px; @@ -167,6 +226,35 @@ display: none; } +.topic-lesson-card-completed-compact-actions { + margin-top: 6px; +} + +.topic-lesson-card-play-again-button { + align-items: center; + background: #fff; + border: 2px solid #49a65c; + border-radius: 10px; + color: #3d9f52; + display: inline-flex; + font-family: Roboto, Arial, sans-serif; + font-size: 16px; + font-weight: 600; + gap: 8px; + height: 50px; + margin-left: auto; + min-width: 160px; + padding: 0 18px; +} + +.topic-lesson-card-play-again-button .material-icons { + font-size: 22px; +} + +.topic-lesson-card-title-completed-collapsed { + color: #3b8f4c; +} + .topic-lesson-card-title { color: #3b3b3b; font-family: Roboto, Arial, sans-serif; @@ -305,6 +393,11 @@ line-height: 1; } +.topic-lesson-card-completed-label.topic-lesson-card-completed-label-compact .material-icons { + color: #40a84f; + font-size: 18px; +} + .topic-lesson-language-selector { flex: 0 1 auto; margin-right: auto; @@ -354,6 +447,31 @@ padding: 4px 8px; } + .topic-lesson-card-completed-label { + font-size: 10px; + padding: 3px 8px; + } + + .topic-lesson-card-completed-label.topic-lesson-card-completed-label-compact { + font-size: 13px; + } + + .topic-lesson-card-play-again-button { + font-size: 13px; + gap: 6px; + height: 38px; + min-width: 126px; + padding: 0 12px; + } + + .topic-lesson-card-play-again-button .material-icons { + font-size: 20px; + } + + .topic-lesson-card-completed-label.topic-lesson-card-completed-label-compact .material-icons { + font-size: 16px; + } + .topic-lesson-card-progress { margin-left: 48px; max-width: 260px; @@ -420,6 +538,38 @@ gap: 4px; } + .topic-lesson-card-title-row.collapsed { + align-items: flex-start; + flex-wrap: wrap; + row-gap: 8px; + } + + .topic-lesson-card-title-row.collapsed .topic-lesson-card-header { + flex: 1 1 0; + min-width: 0; + } + + .topic-lesson-card-play-again-button { + height: 36px; + } + + .topic-lesson-card-chevron-badge { + flex: 0 0 38px; + height: 38px; + width: 38px; + } + + .topic-lesson-card-title-row.collapsed .topic-lesson-card-chevron-badge { + margin-left: auto; + } + + .topic-lesson-card-title-row.collapsed .topic-lesson-card-play-again-button { + justify-content: center; + margin-left: 0; + min-width: 0; + width: 100%; + } + .topic-lesson-card.coming-soon-section-card .topic-lesson-card-copy, .topic-lesson-card.coming-soon-section-card .topic-lesson-card-content.collapsed .topic-lesson-card-copy { gap: 6px; @@ -460,6 +610,11 @@ padding: 4px 7px; } + .topic-lesson-card-completed-label { + font-size: 9px; + padding: 3px 7px; + } + .topic-lesson-card-progress { margin-left: 0; width: 100%; @@ -492,12 +647,6 @@ width: 100%; } - .topic-lesson-card-chevron-badge { - flex: 0 0 38px; - height: 38px; - width: 38px; - } - .topic-lesson-card.coming-soon-section-card .topic-lesson-card-chevron-badge { flex: 0 0 36px; height: 36px; diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/topic-lesson-card/topic-lesson-card.component.html b/core/templates/pages/topic-viewer-page/topic-story-section/topic-lesson-card/topic-lesson-card.component.html index 07d03f92c598e..a7591bed769bf 100644 --- a/core/templates/pages/topic-viewer-page/topic-story-section/topic-lesson-card/topic-lesson-card.component.html +++ b/core/templates/pages/topic-viewer-page/topic-story-section/topic-lesson-card/topic-lesson-card.component.html @@ -1,6 +1,8 @@
@@ -11,7 +13,16 @@
- + +
-

{{ lessonTitle }}

+

+ {{ lessonTitle }} +

{{ 'I18N_TOPIC_VIEWER_LESSON_NEW_LABEL' | translate }} {{ 'I18N_TOPIC_VIEWER_LESSON_COMING_SOON_LABEL' | translate }} + + + {{ 'I18N_TOPIC_VIEWER_LESSON_COMPLETED_LABEL' | translate }} +

{{ isComingSoonLesson ? ('I18N_LEARNER_STORY_TILE_COMING_SOON_TOOLTIP' | translate) : lessonDescription }}

+
-

- {{ adventureGroup.adventureDescription }} -

-
-
- - + +
+
+
+
+ {{ isAdventureCompleted(adventureIdx) ? 'check' : 'menu_book' }} +
+
+

{{ adventureGroup.adventureTitle }}

+

{{ getAdventureCompletionText(adventureIdx) }}

+
+
+
+ expand_more +
+

+ {{ adventureGroup.adventureDescription }} +

+
+
+ + +
-
- - +
+ + +
@@ -111,8 +144,8 @@

{{ adventureGroup.adventureTitle }}

{{ adventureGroup.adventureTitle }}

{{ adventureGroup.adventureTitle }}

+ [attr.aria-label]="'I18N_TOPIC_VIEWER_COMING_SOON_CHAPTERS_ARIA_LABEL' | translate">
-

- {{ 'I18N_TOPIC_VIEWER_COMING_SOON_CHAPTERS' | translate }} -

+

{{ 'I18N_TOPIC_VIEWER_COMING_SOON_CHAPTERS_HEADING' | translate }}

- + {{ 'I18N_TOPIC_VIEWER_CHAPTERS' | translate:{count: comingSoonLessonCards.length, messageFormat: true} }}

+ [actionUrl]="masteryChallengeUrl" + [isUnlocked]="isStoryCompleted()"> diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/topic-story-section.component.spec.ts b/core/templates/pages/topic-viewer-page/topic-story-section/topic-story-section.component.spec.ts index bbf6044f2f3df..e32cc05456f18 100644 --- a/core/templates/pages/topic-viewer-page/topic-story-section/topic-story-section.component.spec.ts +++ b/core/templates/pages/topic-viewer-page/topic-story-section/topic-story-section.component.spec.ts @@ -16,7 +16,7 @@ * @fileoverview Unit tests for TopicStorySectionComponent. */ -import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ElementRef, NO_ERRORS_SCHEMA, QueryList} from '@angular/core'; import { ComponentFixture, TestBed, @@ -26,6 +26,9 @@ import { } from '@angular/core/testing'; import {SimpleChange} from '@angular/core'; import {EventEmitter} from '@angular/core'; +import {MatBottomSheet} from '@angular/material/bottom-sheet'; +import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; +import {TranslateService} from '@ngx-translate/core'; import {StoryNode} from 'domain/story/story-node.model'; import {StorySummary} from 'domain/story/story-summary.model'; @@ -36,12 +39,23 @@ import {AssetsBackendApiService} from 'services/assets-backend-api.service'; import {I18nLanguageCodeService} from 'services/i18n-language-code.service'; import {ChapterLabelVisibilityService} from 'services/chapter-label-visibility.service'; import {PlatformFeatureService} from 'services/platform-feature.service'; +import {WindowRef} from 'services/contextual/window-ref.service'; import {MockTranslatePipe} from 'tests/unit-test-utils'; import {ChapterProgressLoaderService} from 'services/chapter-progress-loader.service'; +import {LocalStorageService} from 'services/local-storage.service'; +import {WindowDimensionsService} from 'services/contextual/window-dimensions.service'; import {TopicStorySectionComponent} from './topic-story-section.component'; +import {AdventureMasteredModalComponent} from './adventure-mastered-modal.component'; +import {ArcSkipConfirmationModalComponent} from './arc-skip-confirmation-modal.component'; import {ChapterProgressSummary} from 'domain/exploration/chapter-progress-summary.model'; +class MockTranslateService { + instant(key: string): string { + return key; + } +} + describe('TopicStorySectionComponent', () => { let component: TopicStorySectionComponent; let fixture: ComponentFixture; @@ -55,6 +69,7 @@ describe('TopicStorySectionComponent', () => { let chapterProgressLoaderService: jasmine.SpyObj; let topicSessionFallbackLanguageService: jasmine.SpyObj; let chapterLabelVisibilityService: jasmine.SpyObj; + let localStorageService: jasmine.SpyObj; let platformFeatureService: { status: { SerialChapterLaunchLearnerView: { @@ -62,12 +77,21 @@ describe('TopicStorySectionComponent', () => { }; }; }; + let windowRef: { + nativeWindow: { + confirm: jasmine.Spy; + }; + }; + let translateService: TranslateService; + let ngbModal: jasmine.SpyObj; + let bottomSheet: jasmine.SpyObj; + let windowDimensionsService: jasmine.SpyObj; beforeEach(waitForAsync(() => { urlService = jasmine.createSpyObj('UrlService', [ - 'getLearnerTopicStudyGuideUrl', 'getClassroomUrlFragmentFromLearnerUrl', 'getTopicUrlFragmentFromLearnerUrl', + 'getQueryFieldValuesAsList', 'addField', ]); urlInterpolationService = jasmine.createSpyObj('UrlInterpolationService', [ @@ -103,6 +127,14 @@ describe('TopicStorySectionComponent', () => { chapterLabelVisibilityService.isNewChapterLabelVisible.and.returnValue( false ); + localStorageService = jasmine.createSpyObj('LocalStorageService', [ + 'getSkippedAdventures', + 'updateSkippedAdventures', + 'getMasteredAdventures', + 'updateMasteredAdventures', + ]); + localStorageService.getSkippedAdventures.and.returnValue([]); + localStorageService.getMasteredAdventures.and.returnValue([]); platformFeatureService = { status: { SerialChapterLaunchLearnerView: { @@ -110,6 +142,21 @@ describe('TopicStorySectionComponent', () => { }, }, }; + windowRef = { + nativeWindow: { + confirm: jasmine.createSpy('confirm').and.returnValue(true), + }, + }; + ngbModal = jasmine.createSpyObj('NgbModal', ['open']); + ngbModal.open.and.returnValue({ + componentInstance: {}, + result: new Promise(() => {}), + } as NgbModalRef); + bottomSheet = jasmine.createSpyObj('MatBottomSheet', ['open']); + windowDimensionsService = jasmine.createSpyObj('WindowDimensionsService', [ + 'getWidth', + ]); + windowDimensionsService.getWidth.and.returnValue(1024); TestBed.configureTestingModule({ declarations: [TopicStorySectionComponent, MockTranslatePipe], @@ -130,10 +177,34 @@ describe('TopicStorySectionComponent', () => { provide: ChapterLabelVisibilityService, useValue: chapterLabelVisibilityService, }, + { + provide: LocalStorageService, + useValue: localStorageService, + }, { provide: PlatformFeatureService, useValue: platformFeatureService, }, + { + provide: WindowRef, + useValue: windowRef, + }, + { + provide: TranslateService, + useClass: MockTranslateService, + }, + { + provide: NgbModal, + useValue: ngbModal, + }, + { + provide: MatBottomSheet, + useValue: bottomSheet, + }, + { + provide: WindowDimensionsService, + useValue: windowDimensionsService, + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); @@ -142,12 +213,12 @@ describe('TopicStorySectionComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(TopicStorySectionComponent); component = fixture.componentInstance; + translateService = TestBed.inject(TranslateService); + spyOn(translateService, 'instant').and.callThrough(); - urlService.getLearnerTopicStudyGuideUrl.and.returnValue( - '/learn/math/place-values/studyguide' - ); urlService.getClassroomUrlFragmentFromLearnerUrl.and.returnValue('math'); urlService.getTopicUrlFragmentFromLearnerUrl.and.returnValue('topic'); + urlService.getQueryFieldValuesAsList.and.returnValue([]); urlService.addField.and.callFake( (url: string, key: string, value: string | number) => `${url}?${key}=${value}` @@ -183,8 +254,10 @@ describe('TopicStorySectionComponent', () => { chapterProgressLoaderService.getChapterProgressSummary.and.returnValue( null ); + }); - fixture.detectChanges(); + afterEach(() => { + component.ngOnDestroy(); }); const createStorySummarySpy = ( @@ -219,6 +292,85 @@ describe('TopicStorySectionComponent', () => { return storySummarySpy as jasmine.SpyObj; }; + const createStoryNodeSpy = ( + title: string, + description: string, + explorationId: string | null, + nodeId: string, + thumbnailFilename: string | null = null, + options: { + status?: string | null; + textLanguageCodes?: string[]; + } = {} + ): jasmine.SpyObj => { + const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ + 'getTitle', + 'getDescription', + 'getThumbnailFilename', + 'getExplorationId', + 'getId', + 'getStatus', + 'getAvailableTextLanguageCodes', + 'getAvailableVoiceoverLanguageCodes', + 'getAvailableVoiceoverLanguageAccentDescriptions', + ]); + storyNodeSpy.getTitle.and.returnValue(title); + storyNodeSpy.getDescription.and.returnValue(description); + storyNodeSpy.getThumbnailFilename.and.returnValue(thumbnailFilename); + storyNodeSpy.getExplorationId.and.returnValue(explorationId); + storyNodeSpy.getId.and.returnValue(nodeId); + storyNodeSpy.getStatus.and.returnValue(options.status); + storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue( + options.textLanguageCodes ?? [] + ); + storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); + storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( + {} + ); + + return storyNodeSpy; + }; + + const createLessonCard = ( + lessonNumber: number, + lessonProgressStatus: + | 'not_started' + | 'in_progress' + | 'completed' + | 'coming_soon' + ) => ({ + lessonNumber: lessonNumber, + lessonTitle: 'Lesson ' + lessonNumber, + lessonDescription: '', + thumbnailUrl: '', + startUrl: '', + practiceUrl: '', + nodeId: 'node_' + lessonNumber, + lessonProgressStatus: lessonProgressStatus, + totalCheckpointsCount: 0, + visitedCheckpointsCount: 0, + isComingSoon: false, + isPublished: true, + isNewLabelVisible: false, + availableTextLanguageCodes: [], + availableVoiceoverLanguageCodes: [], + availableVoiceoverLanguageAccentDescriptions: {}, + }); + + const createAdventureGroup = ( + adventureTitle: string, + lessonCards: ReturnType[] + ) => ({ + adventureTitle: adventureTitle, + adventureDescription: '', + lessonCards: lessonCards, + accentColor: '#27a844', + iconBg: '', + headerBackgroundColor: '', + headerBorderColor: '', + arcId: '1', + }); + it('should expose story meta text helpers', () => { component.lessonCount = 2; component.practiceCount = 1; @@ -228,7 +380,9 @@ describe('TopicStorySectionComponent', () => { }); it('should set study guide url on init', () => { - expect(component.studyGuideUrl).toBe('/learn/math/place-values/studyguide'); + component.ngOnInit(); + + expect(component.studyGuideUrl).toBe('/learn/math/topic/studyguide'); }); it('should fallback avatar image on error', () => { @@ -241,37 +395,21 @@ describe('TopicStorySectionComponent', () => { }); it('should build adventure groups when story has arcs', () => { - const storyNodeSpy1 = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy1.getTitle.and.returnValue('Node title 1'); - storyNodeSpy1.getDescription.and.returnValue('Node description 1'); - storyNodeSpy1.getThumbnailFilename.and.returnValue(null); - storyNodeSpy1.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy1.getId.and.returnValue('node_1'); + const storyNodeSpy1 = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + 'exp_1', + 'node_1', + null + ); - const storyNodeSpy2 = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy2.getTitle.and.returnValue('Node title 2'); - storyNodeSpy2.getDescription.and.returnValue('Node description 2'); - storyNodeSpy2.getThumbnailFilename.and.returnValue(null); - storyNodeSpy2.getExplorationId.and.returnValue('exp_2'); - storyNodeSpy2.getId.and.returnValue('node_2'); + const storyNodeSpy2 = createStoryNodeSpy( + 'Node title 2', + 'Node description 2', + 'exp_2', + 'node_2', + null + ); const arcs = [ { @@ -300,11 +438,13 @@ describe('TopicStorySectionComponent', () => { expect(component.adventureGroups.length).toBe(2); expect(component.adventureGroups[0].adventureTitle).toBe('Adventure 1'); + expect(component.adventureGroups[0].arcId).toBe('1'); expect(component.adventureGroups[0].lessonCards.length).toBe(1); expect(component.adventureGroups[0].lessonCards[0].lessonTitle).toContain( 'Node title 1' ); expect(component.adventureGroups[1].adventureTitle).toBe('Adventure 2'); + expect(component.adventureGroups[1].arcId).toBe('2'); expect(component.adventureGroups[1].lessonCards.length).toBe(1); expect(component.adventureGroups[1].lessonCards[0].lessonTitle).toContain( 'Node title 2' @@ -312,26 +452,15 @@ describe('TopicStorySectionComponent', () => { }); it('should build lesson cards from storySummary and not create practice card', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node title 1'); - storyNodeSpy.getDescription.and.returnValue('Node description 1'); - storyNodeSpy.getThumbnailFilename.and.returnValue('thumb.png'); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue(['en']); - - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + 'exp_1', + 'node_1', + 'thumb.png', + { + textLanguageCodes: ['en'], + } ); component.storySummary = createStorySummarySpy( @@ -353,26 +482,15 @@ describe('TopicStorySectionComponent', () => { }); it('should mark lesson as completed when node is completed', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node title 1'); - storyNodeSpy.getDescription.and.returnValue('Node description 1'); - storyNodeSpy.getThumbnailFilename.and.returnValue('thumb.png'); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue(['en']); - - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + 'exp_1', + 'node_1', + 'thumb.png', + { + textLanguageCodes: ['en'], + } ); const storySummary = createStorySummarySpy( @@ -395,26 +513,15 @@ describe('TopicStorySectionComponent', () => { }); it('should mark lesson as in_progress when node is visited but not completed', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node title 1'); - storyNodeSpy.getDescription.and.returnValue('Node description 1'); - storyNodeSpy.getThumbnailFilename.and.returnValue('thumb.png'); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue(['en']); - - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + 'exp_1', + 'node_1', + 'thumb.png', + { + textLanguageCodes: ['en'], + } ); const storySummary = createStorySummarySpy( @@ -434,26 +541,15 @@ describe('TopicStorySectionComponent', () => { }); it('should load checkpoint counts from chapter progress service on init', async () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node title 1'); - storyNodeSpy.getDescription.and.returnValue('Node description 1'); - storyNodeSpy.getThumbnailFilename.and.returnValue('thumb.png'); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue(['en']); - - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + 'exp_1', + 'node_1', + 'thumb.png', + { + textLanguageCodes: ['en'], + } ); const mockSummary = new ChapterProgressSummary('exp_1', 5, 3, false); @@ -472,7 +568,6 @@ describe('TopicStorySectionComponent', () => { component.ngOnInit(); await fixture.whenStable(); - fixture.detectChanges(); expect(component.lessonCards.length).toBe(1); expect(component.lessonCards[0].totalCheckpointsCount).toBe(5); @@ -480,25 +575,15 @@ describe('TopicStorySectionComponent', () => { }); it('should preserve checkpoint counts after non-story input changes', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node title 1'); - storyNodeSpy.getDescription.and.returnValue('Node description 1'); - storyNodeSpy.getThumbnailFilename.and.returnValue('thumb.png'); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue(['en']); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + 'exp_1', + 'node_1', + 'thumb.png', + { + textLanguageCodes: ['en'], + } ); const mockSummary = new ChapterProgressSummary('exp_1', 3, 1, false); @@ -528,26 +613,15 @@ describe('TopicStorySectionComponent', () => { }); it('should show adventure-end-test card when lesson cards exist and practice is enabled', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node title 1'); - storyNodeSpy.getDescription.and.returnValue('Node description 1'); - storyNodeSpy.getThumbnailFilename.and.returnValue('thumb.png'); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue(['en']); - - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + 'exp_1', + 'node_1', + 'thumb.png', + { + textLanguageCodes: ['en'], + } ); component.storySummary = createStorySummarySpy( @@ -579,7 +653,7 @@ describe('TopicStorySectionComponent', () => { expect(component.lessonCards.length).toBe(0); expect(component.isPracticeCardVisible).toBe(true); expect(component.practiceCard.studyUrl).toBe( - '/learn/math/place-values/studyguide' + '/learn/math/topic/studyguide' ); }); @@ -616,26 +690,15 @@ describe('TopicStorySectionComponent', () => { }); it('should build lesson start url with all fields when exploration id present', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node title 1'); - storyNodeSpy.getDescription.and.returnValue('Node description 1'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue(['en']); - - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + 'exp_1', + 'node_1', + null, + { + textLanguageCodes: ['en'], + } ); const storySummary = createStorySummarySpy( @@ -657,26 +720,15 @@ describe('TopicStorySectionComponent', () => { }); it('should fallback lesson thumbnail when story id is missing', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node title 1'); - storyNodeSpy.getDescription.and.returnValue('Node description 1'); - storyNodeSpy.getThumbnailFilename.and.returnValue('thumb.png'); - storyNodeSpy.getExplorationId.and.returnValue(null); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue(['en']); - - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + null, + 'node_1', + 'thumb.png', + { + textLanguageCodes: ['en'], + } ); const storySummary = createStorySummarySpy( @@ -714,6 +766,7 @@ describe('TopicStorySectionComponent', () => { }); it('should clear fallback selection when language changes', () => { + component.ngOnInit(); topicSessionFallbackLanguageService.clearSelection.calls.reset(); i18nLanguageCodeService.onI18nLanguageCodeChange.emit('es'); @@ -743,25 +796,15 @@ describe('TopicStorySectionComponent', () => { }); it('should construct practice card url when arcs and fragments are present', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node title 1'); - storyNodeSpy.getDescription.and.returnValue('Node description 1'); - storyNodeSpy.getThumbnailFilename.and.returnValue('thumb.png'); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue(['en']); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + 'exp_1', + 'node_1', + 'thumb.png', + { + textLanguageCodes: ['en'], + } ); const storySummary = createStorySummarySpy( @@ -790,6 +833,78 @@ describe('TopicStorySectionComponent', () => { expect(component.practiceCard.practiceUrl).toContain('test/arc/1'); }); + it('should use positional arc id in practice card url for non-numeric arc ids', () => { + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + 'exp_1', + 'node_1', + 'thumb.png', + { + textLanguageCodes: ['en'], + } + ); + + const storySummary = createStorySummarySpy( + ['Node title 1'], + [storyNodeSpy], + [ + { + id: 'default_arc', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + ] + ); + + component.storySummary = storySummary; + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'add'; + component.practiceCount = 1; + component.practiceSubtopicIds = [3]; + + component.ngOnInit(); + + expect(component.isPracticeCardVisible).toBe(true); + expect(component.practiceCard.practiceUrl).toContain('add/test/arc/1'); + expect(component.practiceCard.practiceUrl).not.toContain( + 'add/test/arc/arc' + ); + }); + + it('should return correct practice title for each adventure index', () => { + expect(component.getPracticeTitle(0)).toBe('Adventure 1 Review & Test'); + expect(component.getPracticeTitle(1)).toBe('Adventure 2 Review & Test'); + expect(component.getPracticeTitle(2)).toBe('Adventure 3 Review & Test'); + }); + + it('should return correct practice description with unlock message for non-last adventures', () => { + component.visibleAdventureGroups = [ + createAdventureGroup('Adventure 1', [createLessonCard(1, 'not_started')]), + createAdventureGroup('Adventure 2', [createLessonCard(2, 'not_started')]), + ]; + + expect(component.getPracticeDescription(0)).toBe( + 'Test what you have learned in Adventure 1 to unlock Adventure 2.' + ); + expect(component.getPracticeDescription(1)).toBe( + 'Test what you have learned in Adventure 2.' + ); + }); + + it('should return correct practice description without unlock for last adventure', () => { + component.visibleAdventureGroups = [ + createAdventureGroup('Adventure 1', [createLessonCard(1, 'not_started')]), + createAdventureGroup('Adventure 2', [createLessonCard(2, 'not_started')]), + createAdventureGroup('Adventure 3', [createLessonCard(3, 'not_started')]), + ]; + + expect(component.getPracticeDescription(2)).toBe( + 'Test what you have learned in Adventure 3.' + ); + }); + it('should not create practice card when practice count is zero', () => { component.storySummary = createStorySummarySpy([], []); component.lessonCount = 0; @@ -818,20 +933,17 @@ describe('TopicStorySectionComponent', () => { }); it('should sync on relevant ngOnChanges input updates', () => { - const initialStudyGuideUrl = component.studyGuideUrl; - urlService.getLearnerTopicStudyGuideUrl.and.returnValue('/learn/new/study'); + component.classroomUrlFragment = 'science'; component.ngOnChanges({ storySummary: new SimpleChange(null, null, false), }); - expect(component.studyGuideUrl).not.toBe(initialStudyGuideUrl); - expect(component.studyGuideUrl).toBe('/learn/new/study'); + expect(component.studyGuideUrl).toBe('/learn/science/topic/studyguide'); }); it('should not sync on unrelated ngOnChanges input updates', () => { component.studyGuideUrl = 'unchanged-value'; - urlService.getLearnerTopicStudyGuideUrl.and.returnValue('/learn/new/study'); component.ngOnChanges({ practiceSubtopicIds: new SimpleChange([], [1], false), @@ -851,21 +963,13 @@ describe('TopicStorySectionComponent', () => { }); it('should ignore arc node ids not present in all nodes', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node title 1'); - storyNodeSpy.getDescription.and.returnValue('Node description 1'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + 'exp_1', + 'node_1', + null + ); const arcs = [ { @@ -891,26 +995,15 @@ describe('TopicStorySectionComponent', () => { }); it('should return # as startUrl when exploration id is null', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node title 1'); - storyNodeSpy.getDescription.and.returnValue('Node description 1'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue(null); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue(['en']); - - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + null, + 'node_1', + null, + { + textLanguageCodes: ['en'], + } ); const storySummary = createStorySummarySpy( @@ -927,26 +1020,15 @@ describe('TopicStorySectionComponent', () => { }); it('should handle chapter progress loader failure gracefully', async () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node title 1'); - storyNodeSpy.getDescription.and.returnValue('Node description 1'); - storyNodeSpy.getThumbnailFilename.and.returnValue('thumb.png'); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue(['en']); - - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + 'exp_1', + 'node_1', + 'thumb.png', + { + textLanguageCodes: ['en'], + } ); chapterProgressLoaderService.loadChapterProgressForStory.and.rejectWith( @@ -969,26 +1051,15 @@ describe('TopicStorySectionComponent', () => { }); it('should handle loadChapterProgress with no exploration IDs gracefully', async () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node title 1'); - storyNodeSpy.getDescription.and.returnValue('Node description 1'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue(null); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue(['en']); - - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + null, + 'node_1', + null, + { + textLanguageCodes: ['en'], + } ); const storySummary = createStorySummarySpy( @@ -1006,51 +1077,49 @@ describe('TopicStorySectionComponent', () => { expect(component.lessonCards[0].totalCheckpointsCount).toBe(0); }); + it('should handle an empty node number when loading chapter progress', fakeAsync(() => { + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + 'exp_1', + 'node_', + null + ); + + component.storySummary = createStorySummarySpy( + ['Node title 1'], + [storyNodeSpy] + ); + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + tick(); + + expect(component.lessonCards[0].practiceUrl).toContain( + '/learn/math/topic/practice/' + ); + })); + it('should return empty string for getAdventureCompletionText with invalid index', () => { expect(component.getAdventureCompletionText(999)).toBe(''); }); it('should return correct adventure completion text', () => { - const storyNodeSpy1 = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy1.getTitle.and.returnValue('Node 1'); - storyNodeSpy1.getDescription.and.returnValue('Desc 1'); - storyNodeSpy1.getThumbnailFilename.and.returnValue(null); - storyNodeSpy1.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy1.getId.and.returnValue('node_1'); - storyNodeSpy1.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy1.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy1.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy1 = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null ); - const storyNodeSpy2 = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy2.getTitle.and.returnValue('Node 2'); - storyNodeSpy2.getDescription.and.returnValue('Desc 2'); - storyNodeSpy2.getThumbnailFilename.and.returnValue(null); - storyNodeSpy2.getExplorationId.and.returnValue('exp_2'); - storyNodeSpy2.getId.and.returnValue('node_2'); - storyNodeSpy2.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy2.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy2.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy2 = createStoryNodeSpy( + 'Node 2', + 'Desc 2', + 'exp_2', + 'node_2', + null ); const storySummary = createStorySummarySpy( @@ -1087,25 +1156,12 @@ describe('TopicStorySectionComponent', () => { }); it('should mark lesson as coming_soon when exploration id is null', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Coming Soon Node'); - storyNodeSpy.getDescription.and.returnValue('Description'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue(null); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Coming Soon Node', + 'Description', + null, + 'node_1', + null ); component.storySummary = createStorySummarySpy( @@ -1124,27 +1180,15 @@ describe('TopicStorySectionComponent', () => { it('should mark ready-to-publish lesson as coming soon when serial learner flag is enabled', () => { platformFeatureService.status.SerialChapterLaunchLearnerView.isEnabled = true; - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getStatus', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Coming Soon Node'); - storyNodeSpy.getDescription.and.returnValue('Description'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getStatus.and.returnValue('Ready To Publish'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Coming Soon Node', + 'Description', + 'exp_1', + 'node_1', + null, + { + status: 'Ready To Publish', + } ); component.storySummary = createStorySummarySpy( @@ -1165,25 +1209,15 @@ describe('TopicStorySectionComponent', () => { chapterLabelVisibilityService.isNewChapterLabelVisible.and.returnValue( true ); - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node title 1'); - storyNodeSpy.getDescription.and.returnValue('Node description 1'); - storyNodeSpy.getThumbnailFilename.and.returnValue('thumb.png'); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue(['en']); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node title 1', + 'Node description 1', + 'exp_1', + 'node_1', + 'thumb.png', + { + textLanguageCodes: ['en'], + } ); component.storySummary = createStorySummarySpy( @@ -1210,25 +1244,12 @@ describe('TopicStorySectionComponent', () => { it('should call loadChapterProgress on non-first change of storySummary', async () => { chapterProgressLoaderService.loadChapterProgressForStory.calls.reset(); - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node', + 'Desc', + 'exp_1', + 'node_1', + null ); component.storySummary = createStorySummarySpy(['Node'], [storyNodeSpy]); component.classroomUrlFragment = 'math'; @@ -1245,25 +1266,12 @@ describe('TopicStorySectionComponent', () => { }); it('should return empty adventure groups when arcs are empty', async () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node', + 'Desc', + 'exp_1', + 'node_1', + null ); component.storySummary = createStorySummarySpy( ['Node'], @@ -1281,25 +1289,12 @@ describe('TopicStorySectionComponent', () => { }); it('should select first lesson as active when all lessons are completed', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Completed Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Completed Node', + 'Desc', + 'exp_1', + 'node_1', + null ); const storySummary = createStorySummarySpy( @@ -1320,25 +1315,12 @@ describe('TopicStorySectionComponent', () => { }); it('should handle lesson thumbnail url when node has no thumbnail and story has no id', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node', + 'Desc', + 'exp_1', + 'node_1', + null ); const storySummary = createStorySummarySpy(['Node'], [storyNodeSpy]); @@ -1356,25 +1338,12 @@ describe('TopicStorySectionComponent', () => { }); it('should return # as lesson start url when classroom or topic fragment is missing', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node', + 'Desc', + 'exp_1', + 'node_1', + null ); urlService.getClassroomUrlFragmentFromLearnerUrl.and.returnValue(''); @@ -1390,46 +1359,20 @@ describe('TopicStorySectionComponent', () => { }); it('should handle adventure navigation lesson selected', fakeAsync(() => { - const storyNodeSpy1 = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy1.getTitle.and.returnValue('Node 1'); - storyNodeSpy1.getDescription.and.returnValue('Desc 1'); - storyNodeSpy1.getThumbnailFilename.and.returnValue(null); - storyNodeSpy1.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy1.getId.and.returnValue('node_1'); - storyNodeSpy1.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy1.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy1.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy1 = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null ); - const storyNodeSpy2 = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy2.getTitle.and.returnValue('Node 2'); - storyNodeSpy2.getDescription.and.returnValue('Desc 2'); - storyNodeSpy2.getThumbnailFilename.and.returnValue(null); - storyNodeSpy2.getExplorationId.and.returnValue('exp_2'); - storyNodeSpy2.getId.and.returnValue('node_2'); - storyNodeSpy2.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy2.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy2.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy2 = createStoryNodeSpy( + 'Node 2', + 'Desc 2', + 'exp_2', + 'node_2', + null ); component.storySummary = createStorySummarySpy( @@ -1448,9 +1391,11 @@ describe('TopicStorySectionComponent', () => { component.topicUrlFragment = 'topic'; component.ngOnInit(); - fixture.detectChanges(); - component.onNavigationLessonSelected(2); + component.lessonWrappers = new QueryList(); + component.lessonWrappers.reset([]); + + component.onNavigationLessonSelected({lessonNumber: 2, adventureIndex: 0}); expect(component.activeLessonNumber).toBe(2); expect(component.navigatedLessonNumber).toBe(2); @@ -1459,55 +1404,430 @@ describe('TopicStorySectionComponent', () => { tick(300); })); - it('should handle adventure navigation practice selected when practice card is not rendered', fakeAsync(() => { - component.onNavigationPracticeSelected(0); - - tick(300); + it('should restore skipped adventures from localStorage on init', () => { + localStorageService.getSkippedAdventures.and.returnValue([0]); - expect(component.practiceCardWrappers.length).toBe(0); - })); + const storyNodeSpy1 = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null + ); - it('should select first not_started lesson as active when no in_progress', () => { - const storyNodeSpy1 = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy1.getTitle.and.returnValue('Completed Node'); - storyNodeSpy1.getDescription.and.returnValue('Desc 1'); - storyNodeSpy1.getThumbnailFilename.and.returnValue(null); - storyNodeSpy1.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy1.getId.and.returnValue('node_1'); - storyNodeSpy1.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy1.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy1.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy2 = createStoryNodeSpy( + 'Node 2', + 'Desc 2', + 'exp_2', + 'node_2', + null ); - const storyNodeSpy2 = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy2.getTitle.and.returnValue('Not Started Node'); - storyNodeSpy2.getDescription.and.returnValue('Desc 2'); - storyNodeSpy2.getThumbnailFilename.and.returnValue(null); - storyNodeSpy2.getExplorationId.and.returnValue('exp_2'); - storyNodeSpy2.getId.and.returnValue('node_2'); - storyNodeSpy2.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy2.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy2.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + component.storySummary = createStorySummarySpy( + ['Node 1', 'Node 2'], + [storyNodeSpy1, storyNodeSpy2], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + { + id: 'arc_2', + title: 'Adventure 2', + description: 'Second adventure', + node_ids: ['node_2'], + }, + ] + ); + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + + expect(localStorageService.getSkippedAdventures).toHaveBeenCalledWith( + 'story_id_1' + ); + expect(component.isAdventureSkipped(0)).toBe(true); + expect(component.isAdventureSkipped(1)).toBe(false); + expect(component.isAdventureExpanded(0)).toBe(false); + expect(component.isAdventureExpanded(1)).toBe(true); + }); + + it('should auto-expand first adventure when all adventures are skipped', () => { + localStorageService.getSkippedAdventures.and.returnValue([0, 1]); + + const storyNodeSpy1 = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null + ); + + const storyNodeSpy2 = createStoryNodeSpy( + 'Node 2', + 'Desc 2', + 'exp_2', + 'node_2', + null + ); + + component.storySummary = createStorySummarySpy( + ['Node 1', 'Node 2'], + [storyNodeSpy1, storyNodeSpy2], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + { + id: 'arc_2', + title: 'Adventure 2', + description: 'Second adventure', + node_ids: ['node_2'], + }, + ] + ); + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + + expect(component.isAdventureSkipped(0)).toBe(true); + expect(component.isAdventureSkipped(1)).toBe(true); + expect(component.isAdventureExpanded(0)).toBe(true); + }); + + it('should persist skipped adventures when proceeding with skip confirmation', fakeAsync(() => { + const storyNodeSpy1 = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null + ); + + const storyNodeSpy2 = createStoryNodeSpy( + 'Node 2', + 'Desc 2', + 'exp_2', + 'node_2', + null + ); + + component.storySummary = createStorySummarySpy( + ['Node 1', 'Node 2'], + [storyNodeSpy1, storyNodeSpy2], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + { + id: 'arc_2', + title: 'Adventure 2', + description: 'Second adventure', + node_ids: ['node_2'], + }, + ] + ); + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + + component.lessonWrappers = new QueryList(); + component.lessonWrappers.reset([]); + + component.onNavigationLessonSelected({lessonNumber: 2, adventureIndex: 1}); + + expect(ngbModal.open).toHaveBeenCalledWith( + ArcSkipConfirmationModalComponent, + { + backdrop: 'static', + windowClass: 'oppia-arc-skip-confirmation-modal', + } + ); + expect(localStorageService.updateSkippedAdventures).not.toHaveBeenCalled(); + + component.onArcSkipConfirmationProceed(); + + expect(component.isAdventureSkipped(0)).toBe(true); + expect(localStorageService.updateSkippedAdventures).toHaveBeenCalledWith( + 'story_id_1', + [0] + ); + + tick(300); + })); + + it('should clear skip confirmation state on cancel', () => { + const mockModalRef = { + result: Promise.resolve(), + componentInstance: {}, + } as NgbModalRef; + ngbModal.open.and.returnValue(mockModalRef); + component.onNavigationLessonSelected({lessonNumber: 2, adventureIndex: 1}); + + component.onArcSkipConfirmationCancel(); + + expect(ngbModal.open).toHaveBeenCalled(); + }); + + it('should cancel skip confirmation on proceed when there is no pending navigation', () => { + const mockModalRef = { + result: Promise.resolve(), + componentInstance: {}, + } as NgbModalRef; + ngbModal.open.and.returnValue(mockModalRef); + component.onNavigationLessonSelected({lessonNumber: 2, adventureIndex: 1}); + + component.onArcSkipConfirmationProceed(); + + expect(ngbModal.open).toHaveBeenCalled(); + }); + + it('should not show skip confirmation when all earlier adventures are completed', fakeAsync(() => { + const storyNodeSpy1 = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null + ); + + const storyNodeSpy2 = createStoryNodeSpy( + 'Node 2', + 'Desc 2', + 'exp_2', + 'node_2', + null + ); + + const storySummary = createStorySummarySpy( + ['Node 1', 'Node 2'], + [storyNodeSpy1, storyNodeSpy2], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + { + id: 'arc_2', + title: 'Adventure 2', + description: 'Second adventure', + node_ids: ['node_2'], + }, + ] + ); + storySummary.isNodeCompleted.and.callFake( + (title: string) => title === 'Node 1' + ); + + component.storySummary = storySummary; + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + + expect(component.isAdventureCompleted(0)).toBe(true); + expect(component.isAdventureCompleted(1)).toBe(false); + + component.lessonWrappers = new QueryList(); + component.lessonWrappers.reset([]); + + component.onNavigationLessonSelected({lessonNumber: 2, adventureIndex: 1}); + + expect(ngbModal.open).not.toHaveBeenCalled(); + expect(component.activeLessonNumber).toBe(2); + expect(component.navigatedLessonNumber).toBe(2); + expect(component.isAdventureExpanded(1)).toBe(true); + + tick(300); + })); + + it('should persist un-skipping when a skipped adventure is expanded', () => { + component.skippedAdventureIndices = new Set([0]); + + component.toggleAdventure(0); + + expect(component.isAdventureSkipped(0)).toBe(false); + expect(localStorageService.updateSkippedAdventures).toHaveBeenCalledWith( + 'story_id_1', + [] + ); + }); + + it('should build singular skip confirmation message for one skipped adventure', () => { + component.visibleAdventureGroups = [ + createAdventureGroup('Adventure 1', [createLessonCard(1, 'not_started')]), + createAdventureGroup('Adventure 2', [createLessonCard(2, 'not_started')]), + ]; + + component.onNavigationLessonSelected({lessonNumber: 2, adventureIndex: 1}); + + expect(ngbModal.open).toHaveBeenCalledWith( + ArcSkipConfirmationModalComponent, + { + backdrop: 'static', + windowClass: 'oppia-arc-skip-confirmation-modal', + } + ); + expect(component.getArcSkipConfirmationMessage()).toBe( + 'I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_MESSAGE' + ); + expect(translateService.instant).toHaveBeenCalledWith( + 'I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_MESSAGE', + {count: 1, adventureNumbers: '1', messageFormat: true} + ); + }); + + it('should build plural skip confirmation message for two skipped adventures', () => { + component.visibleAdventureGroups = [ + createAdventureGroup('Adventure 1', [createLessonCard(1, 'not_started')]), + createAdventureGroup('Adventure 2', [createLessonCard(2, 'not_started')]), + createAdventureGroup('Adventure 3', [createLessonCard(3, 'not_started')]), + ]; + + component.onNavigationLessonSelected({lessonNumber: 3, adventureIndex: 2}); + + expect(component.getArcSkipConfirmationMessage()).toBe( + 'I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_MESSAGE' + ); + expect(translateService.instant).toHaveBeenCalledWith( + 'I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_MESSAGE', + {count: 2, adventureNumbers: '1 and 2', messageFormat: true} + ); + }); + + it('should build comma-separated skip confirmation message for three skipped adventures', () => { + component.visibleAdventureGroups = [ + createAdventureGroup('Adventure 1', [createLessonCard(1, 'not_started')]), + createAdventureGroup('Adventure 2', [createLessonCard(2, 'not_started')]), + createAdventureGroup('Adventure 3', [createLessonCard(3, 'not_started')]), + createAdventureGroup('Adventure 4', [createLessonCard(4, 'not_started')]), + ]; + + component.onNavigationLessonSelected({lessonNumber: 4, adventureIndex: 3}); + + expect(component.getArcSkipConfirmationMessage()).toBe( + 'I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_MESSAGE' + ); + expect(translateService.instant).toHaveBeenCalledWith( + 'I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_MESSAGE', + {count: 3, adventureNumbers: '1, 2, and 3', messageFormat: true} + ); + }); + + it('should exclude completed adventures from the skip confirmation message', () => { + component.visibleAdventureGroups = [ + createAdventureGroup('Adventure 1', [createLessonCard(1, 'completed')]), + createAdventureGroup('Adventure 2', [createLessonCard(2, 'not_started')]), + createAdventureGroup('Adventure 3', [createLessonCard(3, 'not_started')]), + ]; + + component.onNavigationLessonSelected({lessonNumber: 3, adventureIndex: 2}); + + expect(component.getArcSkipConfirmationMessage()).toBe( + 'I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_MESSAGE' + ); + expect(translateService.instant).toHaveBeenCalledWith( + 'I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_MESSAGE', + {count: 1, adventureNumbers: '2', messageFormat: true} + ); + }); + + it('should return empty skip confirmation message when there is no pending navigation', () => { + expect(component.getArcSkipConfirmationMessage()).toBe(''); + }); + + it('should return empty skip confirmation message when no earlier adventures are skipped', () => { + component.visibleAdventureGroups = [ + createAdventureGroup('Adventure 1', [createLessonCard(1, 'not_started')]), + createAdventureGroup('Adventure 2', [createLessonCard(2, 'not_started')]), + ]; + + component.onNavigationLessonSelected({lessonNumber: 2, adventureIndex: 1}); + expect(ngbModal.open).toHaveBeenCalledWith( + ArcSkipConfirmationModalComponent, + { + backdrop: 'static', + windowClass: 'oppia-arc-skip-confirmation-modal', + } + ); + + component.visibleAdventureGroups[0].lessonCards[0].lessonProgressStatus = + 'completed'; + + expect(component.getArcSkipConfirmationMessage()).toBe(''); + }); + + it('should return Start label for a skipped adventure that was never started', () => { + component.visibleAdventureGroups = [ + createAdventureGroup('Adventure 1', [createLessonCard(1, 'not_started')]), + ]; + + expect(component.getSkippedAdventureButtonLabel(0)).toBe( + 'I18N_TOPIC_VIEWER_ADVENTURE_START_BUTTON' + ); + }); + + it('should return Resume label for a skipped adventure that was started', () => { + component.visibleAdventureGroups = [ + createAdventureGroup('Adventure 1', [createLessonCard(1, 'in_progress')]), + ]; + + expect(component.getSkippedAdventureButtonLabel(0)).toBe( + 'I18N_TOPIC_VIEWER_ADVENTURE_RESUME_BUTTON' + ); + }); + + it('should return Start label when the adventure group is missing', () => { + component.visibleAdventureGroups = []; + + expect(component.getSkippedAdventureButtonLabel(0)).toBe( + 'I18N_TOPIC_VIEWER_ADVENTURE_START_BUTTON' + ); + }); + + it('should not persist or restore skipped adventures when story id is missing', () => { + (component.storySummary.getId as jasmine.Spy).and.returnValue(''); + localStorageService.getSkippedAdventures.calls.reset(); + localStorageService.updateSkippedAdventures.calls.reset(); + + component.skippedAdventureIndices = new Set([0]); + component.toggleAdventure(0); + + expect(localStorageService.updateSkippedAdventures).not.toHaveBeenCalled(); + + component.ngOnInit(); + + expect(localStorageService.getSkippedAdventures).not.toHaveBeenCalled(); + }); + + it('should select first not_started lesson as active when no in_progress', () => { + const storyNodeSpy1 = createStoryNodeSpy( + 'Completed Node', + 'Desc 1', + 'exp_1', + 'node_1', + null + ); + + const storyNodeSpy2 = createStoryNodeSpy( + 'Not Started Node', + 'Desc 2', + 'exp_2', + 'node_2', + null ); const storySummary = createStorySummarySpy( @@ -1594,7 +1914,7 @@ describe('TopicStorySectionComponent', () => { component.ngOnInit(); - expect(component._expandedAdventureIndices.size).toBe(0); + expect(component.isAdventureExpanded(0)).toBe(false); }); it('should return isPracticeCardVisible from shouldShowAdventureEndTestCard', () => { @@ -1605,138 +1925,134 @@ describe('TopicStorySectionComponent', () => { expect(component.shouldShowAdventureEndTestCard(0)).toBe(false); }); - it('should scroll to the lesson card when navigating to a lesson', fakeAsync(() => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} - ); - - component.storySummary = createStorySummarySpy(['Node'], [storyNodeSpy]); - component.classroomUrlFragment = 'math'; - component.topicUrlFragment = 'topic'; + it('should report story as completed only when all available lessons are completed', () => { + const baseLesson = { + lessonTitle: 'Lesson', + lessonDescription: '', + thumbnailUrl: '', + startUrl: '/explore/1', + practiceUrl: '', + nodeId: 'node_1', + lessonProgressStatus: 'completed' as const, + totalCheckpointsCount: 0, + visitedCheckpointsCount: 0, + isComingSoon: false, + isPublished: true, + isNewLabelVisible: false, + availableTextLanguageCodes: [], + availableVoiceoverLanguageCodes: [], + availableVoiceoverLanguageAccentDescriptions: {}, + }; - component.ngOnInit(); - fixture.detectChanges(); + component.availableLessonCards = [ + {...baseLesson, lessonNumber: 1}, + {...baseLesson, lessonNumber: 2}, + ]; - const lessonWrapper = component.lessonCardWrappers - .toArray() - .find(wrapper => wrapper.nativeElement.id === 'lesson-1'); - expect(lessonWrapper).toBeDefined(); - if (!lessonWrapper) { - fail('Expected lesson wrapper to be defined'); - return; - } - spyOn(lessonWrapper.nativeElement, 'scrollIntoView'); + expect(component.isStoryCompleted()).toBe(true); - component.onNavigationLessonSelected(1); - - tick(300); + component.availableLessonCards = [ + {...baseLesson, lessonNumber: 1}, + { + ...baseLesson, + lessonNumber: 2, + lessonProgressStatus: 'not_started', + }, + ]; - expect(lessonWrapper.nativeElement.scrollIntoView).toHaveBeenCalledWith({ - behavior: 'smooth', - block: 'start', - }); - })); + expect(component.isStoryCompleted()).toBe(false); + }); - it('should scroll to the practice card when navigating to a practice session', fakeAsync(() => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} - ); + it('should report story as not completed when no available lessons exist', () => { + component.availableLessonCards = []; - component.storySummary = createStorySummarySpy( - ['Node'], - [storyNodeSpy], - [ - { - id: 'arc_1', - title: 'Adventure 1', - description: 'First adventure', - node_ids: ['node_1'], - }, - ] - ); - component.classroomUrlFragment = 'math'; - component.topicUrlFragment = 'topic'; - component.practiceCount = 1; + expect(component.isStoryCompleted()).toBe(false); + }); - component.ngOnInit(); - fixture.detectChanges(); + it('should report adventure as completed only when all its lessons are completed', () => { + const baseLesson = { + lessonTitle: 'Lesson', + lessonDescription: '', + thumbnailUrl: '', + startUrl: '/explore/1', + practiceUrl: '', + nodeId: 'node_1', + lessonProgressStatus: 'completed' as const, + totalCheckpointsCount: 0, + visitedCheckpointsCount: 0, + isComingSoon: false, + isPublished: true, + isNewLabelVisible: false, + availableTextLanguageCodes: [], + availableVoiceoverLanguageCodes: [], + availableVoiceoverLanguageAccentDescriptions: {}, + }; - const practiceCardWrapper = component.practiceCardWrappers - .toArray() - .find(wrapper => wrapper.nativeElement.id === 'practice-card-0'); - expect(practiceCardWrapper).toBeDefined(); - if (!practiceCardWrapper) { - fail('Expected practice card wrapper to be defined'); - return; - } - spyOn(practiceCardWrapper.nativeElement, 'scrollIntoView'); + component.visibleAdventureGroups = [ + { + adventureTitle: 'Adventure 1', + adventureDescription: '', + lessonCards: [], + accentColor: '#27a844', + iconBg: '', + headerBackgroundColor: '', + headerBorderColor: '', + arcId: '1', + }, + { + adventureTitle: 'Adventure 2', + adventureDescription: '', + lessonCards: [ + {...baseLesson, lessonNumber: 1}, + {...baseLesson, lessonNumber: 2}, + ], + accentColor: '#27a844', + iconBg: '', + headerBackgroundColor: '', + headerBorderColor: '', + arcId: '2', + }, + { + adventureTitle: 'Adventure 3', + adventureDescription: '', + lessonCards: [ + {...baseLesson, lessonNumber: 3}, + { + ...baseLesson, + lessonNumber: 4, + lessonProgressStatus: 'not_started', + }, + ], + accentColor: '#27a844', + iconBg: '', + headerBackgroundColor: '', + headerBorderColor: '', + arcId: '3', + }, + ]; - component.onNavigationPracticeSelected(0); + expect(component.isAdventureCompleted(0)).toBe(false); + expect(component.isAdventureCompleted(1)).toBe(true); + expect(component.isAdventureCompleted(2)).toBe(false); + expect(component.isAdventureCompleted(99)).toBe(false); + }); - tick(300); + it('should report that missing or empty adventures have incomplete lessons', () => { + component.visibleAdventureGroups = [ + createAdventureGroup('Empty Adventure', []), + ]; - expect( - practiceCardWrapper.nativeElement.scrollIntoView - ).toHaveBeenCalledWith({ - behavior: 'smooth', - block: 'start', - }); - })); + expect(component.areAllLessonsCompleted(0)).toBeFalsy(); + expect(component.areAllLessonsCompleted(1)).toBeFalsy(); + }); it('should handle buildAdventureGroups when arcs is null', async () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node', + 'Desc', + 'exp_1', + 'node_1', + null ); const storySummary = createStorySummarySpy(['Node'], [storyNodeSpy]); @@ -1753,75 +2069,56 @@ describe('TopicStorySectionComponent', () => { }); it('should sync on storyTitle ngOnChanges input update', () => { - urlService.getLearnerTopicStudyGuideUrl.and.returnValue('/learn/new/study'); - component.ngOnChanges({ storyTitle: new SimpleChange('Old Title', 'New Title', false), }); - expect(component.studyGuideUrl).toBe('/learn/new/study'); + expect(component.studyGuideUrl).toBe('/learn/math/topic/studyguide'); }); it('should sync on storyDescription ngOnChanges input update', () => { - urlService.getLearnerTopicStudyGuideUrl.and.returnValue('/learn/new/study'); - component.ngOnChanges({ storyDescription: new SimpleChange('Old', 'New', false), }); - expect(component.studyGuideUrl).toBe('/learn/new/study'); + expect(component.studyGuideUrl).toBe('/learn/math/topic/studyguide'); }); it('should sync on classroomUrlFragment ngOnChanges input update', () => { - urlService.getLearnerTopicStudyGuideUrl.and.returnValue('/learn/new/study'); + component.classroomUrlFragment = 'science'; component.ngOnChanges({ classroomUrlFragment: new SimpleChange('', 'science', false), }); - expect(component.studyGuideUrl).toBe('/learn/new/study'); + expect(component.studyGuideUrl).toBe('/learn/science/topic/studyguide'); }); it('should sync on topicUrlFragment ngOnChanges input update', () => { - urlService.getLearnerTopicStudyGuideUrl.and.returnValue('/learn/new/study'); + component.topicUrlFragment = 'biology'; component.ngOnChanges({ topicUrlFragment: new SimpleChange('', 'biology', false), }); - expect(component.studyGuideUrl).toBe('/learn/new/study'); + expect(component.studyGuideUrl).toBe('/learn/math/biology/studyguide'); }); it('should sync on lessonCount ngOnChanges input update', () => { - urlService.getLearnerTopicStudyGuideUrl.and.returnValue('/learn/new/study'); - component.ngOnChanges({ lessonCount: new SimpleChange(0, 5, false), }); - expect(component.studyGuideUrl).toBe('/learn/new/study'); + expect(component.studyGuideUrl).toBe('/learn/math/topic/studyguide'); }); it('should return # as lesson start url when only topic fragment is missing', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node', + 'Desc', + 'exp_1', + 'node_1', + null ); urlService.getClassroomUrlFragmentFromLearnerUrl.and.returnValue('math'); @@ -1837,25 +2134,12 @@ describe('TopicStorySectionComponent', () => { }); it('should return # as lesson start url when only classroom fragment is missing', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node', + 'Desc', + 'exp_1', + 'node_1', + null ); urlService.getClassroomUrlFragmentFromLearnerUrl.and.returnValue(''); @@ -1882,25 +2166,12 @@ describe('TopicStorySectionComponent', () => { }); it('should handle getActiveLessonNumber when visitedChapterTitles is null', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node', + 'Desc', + 'exp_1', + 'node_1', + null ); const storySummary = createStorySummarySpy(['Node'], [storyNodeSpy]); @@ -1916,27 +2187,15 @@ describe('TopicStorySectionComponent', () => { }); it('should populate adventureNavigationGroups with lesson numbers and accent colors', async () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getStatus', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getStatus.and.returnValue('Published'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node', + 'Desc', + 'exp_1', + 'node_1', + null, + { + status: 'Published', + } ); component.storySummary = createStorySummarySpy( @@ -1961,57 +2220,78 @@ describe('TopicStorySectionComponent', () => { expect(component.adventureNavigationGroups[0].lessons).toEqual([ { lessonNumber: 1, + isCompleted: false, }, ]); expect(component.adventureNavigationGroups[0].accentColor).toBe('#27a844'); expect(component.adventureNavigationGroups[0].showPractice).toBe(true); + expect(component.adventureNavigationGroups[0].isPracticeCompleted).toBe( + false + ); }); - it('should exclude non-published lessons from adventure navigation groups', () => { - const publishedNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getStatus', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - publishedNodeSpy.getTitle.and.returnValue('Published Node'); - publishedNodeSpy.getDescription.and.returnValue('Desc'); - publishedNodeSpy.getThumbnailFilename.and.returnValue(null); - publishedNodeSpy.getExplorationId.and.returnValue('exp_1'); - publishedNodeSpy.getId.and.returnValue('node_1'); - publishedNodeSpy.getStatus.and.returnValue('Published'); - publishedNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - publishedNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - publishedNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + it('should mark completed lessons in adventureNavigationGroups', async () => { + const storyNodeSpy = createStoryNodeSpy( + 'Completed Node', + 'Desc', + 'exp_1', + 'node_1', + null, + { + status: 'Published', + } ); - const draftNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getStatus', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', + component.storySummary = createStorySummarySpy( + ['Completed Node'], + [storyNodeSpy], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + ] + ); + (component.storySummary.isNodeCompleted as jasmine.Spy).and.returnValue( + true + ); + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + await fixture.whenStable(); + + expect(component.adventureNavigationGroups[0].lessons).toEqual([ + { + lessonNumber: 1, + isCompleted: true, + }, ]); - draftNodeSpy.getTitle.and.returnValue('Draft Node'); - draftNodeSpy.getDescription.and.returnValue('Desc'); - draftNodeSpy.getThumbnailFilename.and.returnValue(null); - draftNodeSpy.getExplorationId.and.returnValue('exp_2'); - draftNodeSpy.getId.and.returnValue('node_2'); - draftNodeSpy.getStatus.and.returnValue('Draft'); - draftNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - draftNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - draftNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + }); + + it('should exclude non-published lessons from adventure navigation groups', () => { + const publishedNodeSpy = createStoryNodeSpy( + 'Published Node', + 'Desc', + 'exp_1', + 'node_1', + null, + { + status: 'Published', + } + ); + + const draftNodeSpy = createStoryNodeSpy( + 'Draft Node', + 'Desc', + 'exp_2', + 'node_2', + null, + { + status: 'Draft', + } ); component.storySummary = createStorySummarySpy( @@ -2039,9 +2319,11 @@ describe('TopicStorySectionComponent', () => { expect(component.adventureNavigationGroups).toEqual([ { - lessons: [{lessonNumber: 1}], + lessons: [{lessonNumber: 1, isCompleted: false}], accentColor: '#27a844', showPractice: true, + isPracticeCompleted: false, + arcId: '1', }, ]); }); @@ -2052,9 +2334,14 @@ describe('TopicStorySectionComponent', () => { component.topicUrlFragment = 'topic'; component.ngOnInit(); - fixture.detectChanges(); - component.onNavigationLessonSelected(999); + component.lessonWrappers = new QueryList(); + component.lessonWrappers.reset([]); + + component.onNavigationLessonSelected({ + lessonNumber: 999, + adventureIndex: 0, + }); expect(component.activeLessonNumber).toBe(999); expect(component.navigatedLessonNumber).toBe(999); @@ -2091,25 +2378,12 @@ describe('TopicStorySectionComponent', () => { }); it('should populate lessonCount from storySummary on init', async () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node', + 'Desc', + 'exp_1', + 'node_1', + null ); component.storySummary = createStorySummarySpy( @@ -2142,25 +2416,15 @@ describe('TopicStorySectionComponent', () => { }); it('should set lessonProgressStatus from loadChapterProgress for completed node', async () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node 1'); - storyNodeSpy.getDescription.and.returnValue('Desc 1'); - storyNodeSpy.getThumbnailFilename.and.returnValue('thumb.png'); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue(['en']); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + 'thumb.png', + { + textLanguageCodes: ['en'], + } ); const storySummary = createStorySummarySpy(['Node 1'], [storyNodeSpy]); @@ -2218,25 +2482,12 @@ describe('TopicStorySectionComponent', () => { chapterLabelVisibilityService.isNewChapterLabelVisible.and.throwError( 'Service error' ); - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + const storyNodeSpy = createStoryNodeSpy( + 'Node', + 'Desc', + 'exp_1', + 'node_1', + null ); component.storySummary = createStorySummarySpy(['Node'], [storyNodeSpy]); @@ -2249,147 +2500,1243 @@ describe('TopicStorySectionComponent', () => { }); it('should return false from isChapterPublished when getStatus throws', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getStatus', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); + const storyNodeSpy = createStoryNodeSpy( + 'Node', + 'Desc', + 'exp_1', + 'node_1', + null + ); storyNodeSpy.getStatus.and.throwError('Status error'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + + component.storySummary = createStorySummarySpy(['Node'], [storyNodeSpy]); + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + + expect(component.lessonCards[0].isPublished).toBe(false); + }); + + it('should return false from isChapterReadyToPublish when getStatus throws and serial flag enabled', () => { + platformFeatureService.status.SerialChapterLaunchLearnerView.isEnabled = + true; + const storyNodeSpy = createStoryNodeSpy( + 'Node', + 'Desc', + 'exp_1', + 'node_1', + null + ); + storyNodeSpy.getStatus.and.throwError('Status error'); + + component.storySummary = createStorySummarySpy(['Node'], [storyNodeSpy]); + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + + // When getStatus throws and serial flag is enabled, isComingSoon falls back to false + // (getExplorationId is set so it won't be coming_soon via the null check) + expect(component.lessonCards[0].lessonProgressStatus).toBe('not_started'); + }); + + it('should show adventure mastered modal when returning from completed arc test', fakeAsync(() => { + const createNode = (nodeId: string, title: string) => { + const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ + 'getTitle', + 'getDescription', + 'getThumbnailFilename', + 'getExplorationId', + 'getId', + 'getStatus', + 'getAvailableTextLanguageCodes', + 'getAvailableVoiceoverLanguageCodes', + 'getAvailableVoiceoverLanguageAccentDescriptions', + ]); + storyNodeSpy.getTitle.and.returnValue(title); + storyNodeSpy.getDescription.and.returnValue('Desc'); + storyNodeSpy.getThumbnailFilename.and.returnValue(null); + storyNodeSpy.getExplorationId.and.returnValue('exp_' + nodeId); + storyNodeSpy.getId.and.returnValue(nodeId); + storyNodeSpy.getStatus.and.returnValue('Published'); + storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); + storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); + storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( + {} + ); + return storyNodeSpy; + }; + + const node1 = createNode('node_1', 'Node 1'); + const node2 = createNode('node_2', 'Node 2'); + const node3 = createNode('node_3', 'Node 3'); + const node4 = createNode('node_4', 'Node 4'); + + component.storySummary = createStorySummarySpy( + ['Node 1', 'Node 2', 'Node 3', 'Node 4'], + [node1, node2, node3, node4], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1', 'node_2', 'node_3'], + }, + { + id: 'arc_2', + title: 'Adventure 2', + description: 'Second adventure', + node_ids: ['node_4'], + }, + ] + ); + (component.storySummary.isNodeCompleted as jasmine.Spy).and.callFake( + (title: string) => title !== 'Node 4' + ); + urlService.getQueryFieldValuesAsList.and.callFake((fieldName: string) => { + if (fieldName === 'arc_mastered') { + return ['true']; + } + if (fieldName === 'arc_id') { + return ['1']; + } + return []; + }); + + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + component.ngOnInit(); + tick(); + + expect(ngbModal.open).toHaveBeenCalledWith( + AdventureMasteredModalComponent, + { + backdrop: 'static', + windowClass: 'oppia-adventure-mastered-modal', + } + ); + expect(component.masteredAdventureIndex).toBe(0); + expect(component.getAdventureMasteredTitle()).toBe( + 'I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_NUMBER_TITLE' + ); + expect(translateService.instant).toHaveBeenCalledWith( + 'I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_NUMBER_TITLE', + {adventureNumber: 1} + ); + })); + + it('should handle malformed arc_id query values when showing mastered modal', fakeAsync(() => { + const createNode = (nodeId: string, title: string) => { + const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ + 'getTitle', + 'getDescription', + 'getThumbnailFilename', + 'getExplorationId', + 'getId', + 'getStatus', + 'getAvailableTextLanguageCodes', + 'getAvailableVoiceoverLanguageCodes', + 'getAvailableVoiceoverLanguageAccentDescriptions', + ]); + storyNodeSpy.getTitle.and.returnValue(title); + storyNodeSpy.getDescription.and.returnValue('Desc'); + storyNodeSpy.getThumbnailFilename.and.returnValue(null); + storyNodeSpy.getExplorationId.and.returnValue('exp_' + nodeId); + storyNodeSpy.getId.and.returnValue(nodeId); + storyNodeSpy.getStatus.and.returnValue('Published'); + storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); + storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); + storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( + {} + ); + return storyNodeSpy; + }; + + const node1 = createNode('node_1', 'Node 1'); + const node2 = createNode('node_2', 'Node 2'); + const node3 = createNode('node_3', 'Node 3'); + + component.storySummary = createStorySummarySpy( + ['Node 1', 'Node 2', 'Node 3'], + [node1, node2, node3], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1', 'node_2', 'node_3'], + }, + ] + ); + (component.storySummary.isNodeCompleted as jasmine.Spy).and.returnValue( + true + ); + urlService.getQueryFieldValuesAsList.and.callFake((fieldName: string) => { + if (fieldName === 'arc_mastered') { + return ['true']; + } + if (fieldName === 'arc_id') { + return ['1/story']; + } + return []; + }); + + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + component.ngOnInit(); + tick(); + + expect(ngbModal.open).toHaveBeenCalledWith( + AdventureMasteredModalComponent, + { + backdrop: 'static', + windowClass: 'oppia-adventure-mastered-modal', + } + ); + expect(component.masteredAdventureIndex).toBe(0); + })); + + it('should collapse mastered adventure when continuing from mastered modal', () => { + component.visibleAdventureGroups = [ + createAdventureGroup('Adventure 1', [createLessonCard(1, 'completed')]), + createAdventureGroup('Adventure 2', [createLessonCard(2, 'not_started')]), + ]; + component.masteredAdventureIndex = 0; + component.toggleAdventure(0); + + component.onAdventureMasteredContinue(); + + expect(component.masteredAdventureIndex).toBeNull(); + expect(component.isAdventureExpanded(0)).toBe(false); + expect(component.isAdventureExpanded(1)).toBe(true); + expect(component.activeLessonNumber).toBe(2); + }); + + it('should handle onNavigationPracticeSelected by scrolling to practice card', fakeAsync(() => { + const storyNodeSpy = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null, + {status: 'Published'} + ); + + component.storySummary = createStorySummarySpy( + ['Node 1'], + [storyNodeSpy], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + ] + ); + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + + const practiceElement = jasmine.createSpyObj( + 'practiceElement', + ['scrollIntoView'] + ); + practiceElement.id = 'practice-card-1'; + component.practiceWrappers = new QueryList(); + component.practiceWrappers.reset([ + {nativeElement: practiceElement} as ElementRef, + ]); + + component.onNavigationPracticeSelected('1'); + tick(300); + + expect(practiceElement.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'start', + }); + })); + + it('should handle onNavigationPracticeSelected when element is not found', fakeAsync(() => { + component.practiceWrappers = new QueryList(); + component.practiceWrappers.reset([]); + + component.onNavigationPracticeSelected('999'); + tick(300); + })); + + it('should handle onAdventureMasteredContinue when masteredAdventureIndex is null', () => { + component.masteredAdventureIndex = null; + + component.onAdventureMasteredContinue(); + + expect(component.masteredAdventureIndex).toBeNull(); + }); + + it('should handle onAdventureMasteredContinue when mastered the last adventure', () => { + component.visibleAdventureGroups = [ + createAdventureGroup('Adventure 1', [createLessonCard(1, 'completed')]), + ]; + component.masteredAdventureIndex = 0; + + component.onAdventureMasteredContinue(); + + expect(component.masteredAdventureIndex).toBeNull(); + expect(component.isAdventureExpanded(0)).toBe(false); + expect(Reflect.get(component, 'hasHandledArcMasteredQueryParams')).toBe( + true + ); + }); + + it('should call onArcSkipConfirmationCancel when arc skip modal is rejected', fakeAsync(() => { + const storyNodeSpy1 = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null + ); + const storyNodeSpy2 = createStoryNodeSpy( + 'Node 2', + 'Desc 2', + 'exp_2', + 'node_2', + null + ); + + component.storySummary = createStorySummarySpy( + ['Node 1', 'Node 2'], + [storyNodeSpy1, storyNodeSpy2], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + { + id: 'arc_2', + title: 'Adventure 2', + description: 'Second adventure', + node_ids: ['node_2'], + }, + ] + ); + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + + component.lessonWrappers = new QueryList(); + component.lessonWrappers.reset([]); + + const rejectModalRef = { + result: Promise.reject(new Error('dismissed')), + componentInstance: {}, + } as NgbModalRef; + ngbModal.open.and.returnValue(rejectModalRef); + + component.onNavigationLessonSelected({lessonNumber: 2, adventureIndex: 1}); + + expect(ngbModal.open).toHaveBeenCalledWith( + ArcSkipConfirmationModalComponent, + { + backdrop: 'static', + windowClass: 'oppia-arc-skip-confirmation-modal', + } + ); + + tick(); + + expect(Reflect.get(component, 'pendingNavigationLessonNumber')).toBeNull(); + expect( + Reflect.get(component, 'pendingNavigationAdventureIndex') + ).toBeNull(); + })); + + it('should call onAdventureMasteredContinue when mastered modal is resolved', fakeAsync(() => { + const storyNodeSpy = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null, + {status: 'Published'} + ); + + component.storySummary = createStorySummarySpy( + ['Node 1'], + [storyNodeSpy], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + ] + ); + (component.storySummary.isNodeCompleted as jasmine.Spy).and.returnValue( + true + ); + urlService.getQueryFieldValuesAsList.and.callFake((fieldName: string) => { + if (fieldName === 'arc_mastered') { + return ['true']; + } + if (fieldName === 'arc_id') { + return ['1']; + } + return []; + }); + + const resolveModalRef = { + result: Promise.resolve(), + componentInstance: {}, + } as NgbModalRef; + ngbModal.open.and.returnValue(resolveModalRef); + + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + tick(); + + expect(ngbModal.open).toHaveBeenCalledWith( + AdventureMasteredModalComponent, + { + backdrop: 'static', + windowClass: 'oppia-adventure-mastered-modal', + } + ); + expect(component.masteredAdventureIndex).toBeNull(); + expect(Reflect.get(component, 'hasHandledArcMasteredQueryParams')).toBe( + true + ); + })); + + it('should clear mastered modal ref when mastered modal is rejected', fakeAsync(() => { + const storyNodeSpy = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null, + {status: 'Published'} + ); + + component.storySummary = createStorySummarySpy( + ['Node 1'], + [storyNodeSpy], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + ] + ); + (component.storySummary.isNodeCompleted as jasmine.Spy).and.returnValue( + true + ); + urlService.getQueryFieldValuesAsList.and.callFake((fieldName: string) => { + if (fieldName === 'arc_mastered') { + return ['true']; + } + if (fieldName === 'arc_id') { + return ['1']; + } + return []; + }); + + const rejectModalRef = { + result: Promise.reject(new Error('dismissed')), + componentInstance: {}, + } as NgbModalRef; + ngbModal.open.and.returnValue(rejectModalRef); + + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + tick(); + + expect(ngbModal.open).toHaveBeenCalledWith( + AdventureMasteredModalComponent, + { + backdrop: 'static', + windowClass: 'oppia-adventure-mastered-modal', + } + ); + + tick(1); + })); + + it('should return false from isChapterPublished when getStatus returns null', () => { + const storyNodeSpy = createStoryNodeSpy( + 'Node', + 'Desc', + 'exp_1', + 'node_1', + null, + { + status: null, + } + ); + + component.storySummary = createStorySummarySpy(['Node'], [storyNodeSpy]); + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + + expect(component.lessonCards[0].isPublished).toBe(false); + }); + + it('should return default mastered modal text when no adventure is mastered', () => { + expect(component.getAdventureMasteredTitle()).toBe( + 'I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_TITLE' + ); + expect(component.getAdventureMasteredSubtitle()).toBe( + 'I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_MOMENTUM_SUBTITLE' + ); + }); + + it('should show the unlocked adventure in the mastered modal subtitle', () => { + component.visibleAdventureGroups = [ + createAdventureGroup('Adventure 1', [createLessonCard(1, 'completed')]), + createAdventureGroup('Adventure 2', [createLessonCard(2, 'not_started')]), + ]; + component.masteredAdventureIndex = 0; + + expect(component.getAdventureMasteredSubtitle()).toBe( + 'I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_UNLOCKED_SUBTITLE' + ); + expect(translateService.instant).toHaveBeenCalledWith( + 'I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_UNLOCKED_SUBTITLE', + {adventureNumber: 2} + ); + }); + + it('should show the all-adventures-mastered text in the mastered modal subtitle', () => { + component.visibleAdventureGroups = [ + createAdventureGroup('Adventure 1', [createLessonCard(1, 'completed')]), + ]; + component.masteredAdventureIndex = 0; + + expect(component.getAdventureMasteredSubtitle()).toBe( + 'I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_ALL_COMPLETE_SUBTITLE' + ); + }); + + it('should return false from isAdventurePracticeCompleted when the adventure group is missing', () => { + component.visibleAdventureGroups = []; + + expect(component.isAdventurePracticeCompleted(0)).toBe(false); + }); + + it('should report practice completion for a mastered adventure arc', fakeAsync(() => { + const storyNodeSpy = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null, + { + status: 'Published', + } + ); + + component.storySummary = createStorySummarySpy( + ['Node 1'], + [storyNodeSpy], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + ] + ); + (component.storySummary.isNodeCompleted as jasmine.Spy).and.returnValue( + true + ); + urlService.getQueryFieldValuesAsList.and.callFake((fieldName: string) => { + if (fieldName === 'arc_mastered') { + return ['true']; + } + if (fieldName === 'arc_id') { + return ['1']; + } + return []; + }); + + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + tick(); + + expect(ngbModal.open).toHaveBeenCalledWith( + AdventureMasteredModalComponent, + { + backdrop: 'static', + windowClass: 'oppia-adventure-mastered-modal', + } + ); + expect(component.isAdventurePracticeCompleted(0)).toBe(true); + })); + + it('should not show the mastered modal when arc_id does not start with a digit', fakeAsync(() => { + const storyNodeSpy = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null, + { + status: 'Published', + } + ); + + component.storySummary = createStorySummarySpy( + ['Node 1'], + [storyNodeSpy], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + ] + ); + (component.storySummary.isNodeCompleted as jasmine.Spy).and.returnValue( + true + ); + urlService.getQueryFieldValuesAsList.and.callFake((fieldName: string) => { + if (fieldName === 'arc_mastered') { + return ['true']; + } + if (fieldName === 'arc_id') { + return ['abc']; + } + return []; + }); + + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + tick(); + + expect(ngbModal.open).not.toHaveBeenCalled(); + })); + + it('should not show the mastered modal when arc_id is empty', fakeAsync(() => { + const storyNodeSpy = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null, + { + status: 'Published', + } + ); + + component.storySummary = createStorySummarySpy( + ['Node 1'], + [storyNodeSpy], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + ] + ); + (component.storySummary.isNodeCompleted as jasmine.Spy).and.returnValue( + true + ); + urlService.getQueryFieldValuesAsList.and.callFake((fieldName: string) => { + if (fieldName === 'arc_mastered') { + return ['true']; + } + if (fieldName === 'arc_id') { + return ['']; + } + return []; + }); + + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + tick(); + + expect(ngbModal.open).not.toHaveBeenCalled(); + })); + + it('should not show the mastered modal when arc_id does not match any adventure', fakeAsync(() => { + const storyNodeSpy = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null, + { + status: 'Published', + } + ); + + component.storySummary = createStorySummarySpy( + ['Node 1'], + [storyNodeSpy], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + ] + ); + (component.storySummary.isNodeCompleted as jasmine.Spy).and.returnValue( + true + ); + urlService.getQueryFieldValuesAsList.and.callFake((fieldName: string) => { + if (fieldName === 'arc_mastered') { + return ['true']; + } + if (fieldName === 'arc_id') { + return ['5']; + } + return []; + }); + + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + tick(); + + expect(ngbModal.open).not.toHaveBeenCalled(); + })); + + it('should not show the mastered modal again after it has been handled', fakeAsync(() => { + const createNode = (nodeId: string, title: string) => { + const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ + 'getTitle', + 'getDescription', + 'getThumbnailFilename', + 'getExplorationId', + 'getId', + 'getStatus', + 'getAvailableTextLanguageCodes', + 'getAvailableVoiceoverLanguageCodes', + 'getAvailableVoiceoverLanguageAccentDescriptions', + ]); + storyNodeSpy.getTitle.and.returnValue(title); + storyNodeSpy.getDescription.and.returnValue('Desc'); + storyNodeSpy.getThumbnailFilename.and.returnValue(null); + storyNodeSpy.getExplorationId.and.returnValue('exp_' + nodeId); + storyNodeSpy.getId.and.returnValue(nodeId); + storyNodeSpy.getStatus.and.returnValue('Published'); + storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); + storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); + storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( + {} + ); + return storyNodeSpy; + }; + + const node1 = createNode('node_1', 'Node 1'); + const node2 = createNode('node_2', 'Node 2'); + const node3 = createNode('node_3', 'Node 3'); + const node4 = createNode('node_4', 'Node 4'); + + component.storySummary = createStorySummarySpy( + ['Node 1', 'Node 2', 'Node 3', 'Node 4'], + [node1, node2, node3, node4], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1', 'node_2', 'node_3'], + }, + { + id: 'arc_2', + title: 'Adventure 2', + description: 'Second adventure', + node_ids: ['node_4'], + }, + ] + ); + (component.storySummary.isNodeCompleted as jasmine.Spy).and.callFake( + (title: string) => title !== 'Node 4' + ); + urlService.getQueryFieldValuesAsList.and.callFake((fieldName: string) => { + if (fieldName === 'arc_mastered') { + return ['true']; + } + if (fieldName === 'arc_id') { + return ['1']; + } + return []; + }); + + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + component.ngOnInit(); + tick(); + + expect(ngbModal.open).toHaveBeenCalledWith( + AdventureMasteredModalComponent, + { + backdrop: 'static', + windowClass: 'oppia-adventure-mastered-modal', + } + ); + + component.onAdventureMasteredContinue(); + + component.ngOnChanges({ + storySummary: new SimpleChange( + component.storySummary, + component.storySummary, + false + ), + }); + tick(); + + expect(ngbModal.open).toHaveBeenCalledTimes(1); + })); + + it('should forward the topic editor preview flag to the adventure navigation', () => { + component.isInTopicEditorPreview = true; + fixture.detectChanges(); + + const navigationElement = fixture.nativeElement.querySelector( + 'topic-adventure-navigation' + ); + expect(navigationElement).not.toBeNull(); + expect(navigationElement.isInTopicEditorPreview).toBeTruthy(); + }); + + it('should not forward the topic editor preview flag by default', () => { + component.isInTopicEditorPreview = false; + fixture.detectChanges(); + + const navigationElement = fixture.nativeElement.querySelector( + 'topic-adventure-navigation' + ); + expect(navigationElement).not.toBeNull(); + expect(navigationElement.isInTopicEditorPreview).toBeFalsy(); + }); + + it('should handle adventure navigation practice selected when element not found', fakeAsync(() => { + component.practiceWrappers = new QueryList(); + component.practiceWrappers.reset([]); + + component.onNavigationPracticeSelected('1'); + tick(300); + })); + + it('should scroll to lesson element when found by ViewChildren', fakeAsync(() => { + const lessonElement = jasmine.createSpyObj('lessonElement', [ + 'scrollIntoView', + ]); + lessonElement.id = 'lesson-1'; + component.lessonWrappers = new QueryList(); + component.lessonWrappers.reset([ + {nativeElement: lessonElement} as ElementRef, + ]); + + component.onNavigationLessonSelected({ + lessonNumber: 1, + adventureIndex: 0, + }); + tick(300); + + expect(lessonElement.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'start', + }); + })); + + it('should scroll to practice card element when found by ViewChildren', fakeAsync(() => { + const practiceElement = jasmine.createSpyObj( + 'practiceElement', + ['scrollIntoView'] + ); + practiceElement.id = 'practice-card-1'; + component.practiceWrappers = new QueryList(); + component.practiceWrappers.reset([ + {nativeElement: practiceElement} as ElementRef, + ]); + + component.onNavigationPracticeSelected('1'); + tick(300); + + expect(practiceElement.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'start', + }); + })); + + it('should restore mastered adventures from localStorage on init', () => { + localStorageService.getMasteredAdventures.and.returnValue(['1']); + + const storyNodeSpy1 = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null + ); + const storyNodeSpy2 = createStoryNodeSpy( + 'Node 2', + 'Desc 2', + 'exp_2', + 'node_2', + null + ); + + component.storySummary = createStorySummarySpy( + ['Node 1', 'Node 2'], + [storyNodeSpy1, storyNodeSpy2], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + { + id: 'arc_2', + title: 'Adventure 2', + description: 'Second adventure', + node_ids: ['node_2'], + }, + ] + ); + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + + expect(localStorageService.getMasteredAdventures).toHaveBeenCalledWith( + 'story_id_1' + ); + expect(component.isAdventurePracticeCompleted(0)).toBe(true); + expect(component.isAdventurePracticeCompleted(1)).toBe(false); + }); + + it('should persist mastered adventures when returning from arc test', fakeAsync(() => { + const storyNodeSpy1 = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null, + {status: 'Published'} + ); + const storyNodeSpy2 = createStoryNodeSpy( + 'Node 2', + 'Desc 2', + 'exp_2', + 'node_2', + null, + {status: 'Published'} + ); + + component.storySummary = createStorySummarySpy( + ['Node 1', 'Node 2'], + [storyNodeSpy1, storyNodeSpy2], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + { + id: 'arc_2', + title: 'Adventure 2', + description: 'Second adventure', + node_ids: ['node_2'], + }, + ] + ); + (component.storySummary.isNodeCompleted as jasmine.Spy).and.callFake( + (title: string) => title === 'Node 1' + ); + urlService.getQueryFieldValuesAsList.and.callFake((fieldName: string) => { + if (fieldName === 'arc_mastered') { + return ['true']; + } + if (fieldName === 'arc_id') { + return ['1']; + } + return []; + }); + + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + component.ngOnInit(); + tick(); + + expect(ngbModal.open).toHaveBeenCalledWith( + AdventureMasteredModalComponent, + { + backdrop: 'static', + windowClass: 'oppia-adventure-mastered-modal', + } + ); + expect(localStorageService.updateMasteredAdventures).toHaveBeenCalledWith( + 'story_id_1', + ['1'] + ); + })); + + it('should retain mastered adventures across page reload from localStorage', () => { + localStorageService.getMasteredAdventures.and.returnValue(['1', '2']); + + const storyNodeSpy1 = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null + ); + const storyNodeSpy2 = createStoryNodeSpy( + 'Node 2', + 'Desc 2', + 'exp_2', + 'node_2', + null + ); + const storyNodeSpy3 = createStoryNodeSpy( + 'Node 3', + 'Desc 3', + 'exp_3', + 'node_3', + null + ); + + component.storySummary = createStorySummarySpy( + ['Node 1', 'Node 2', 'Node 3'], + [storyNodeSpy1, storyNodeSpy2, storyNodeSpy3], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + { + id: 'arc_2', + title: 'Adventure 2', + description: 'Second adventure', + node_ids: ['node_2'], + }, + { + id: 'arc_3', + title: 'Adventure 3', + description: 'Third adventure', + node_ids: ['node_3'], + }, + ] + ); + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + + component.ngOnInit(); + + expect(component.isAdventurePracticeCompleted(0)).toBe(true); + expect(component.isAdventurePracticeCompleted(1)).toBe(true); + expect(component.isAdventurePracticeCompleted(2)).toBe(false); + }); + + it('should not persist or restore mastered adventures when story id is missing', () => { + const storyNodeSpy = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null, + {status: 'Published'} + ); + component.storySummary = createStorySummarySpy( + ['Node 1'], + [storyNodeSpy], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + ] ); - - component.storySummary = createStorySummarySpy(['Node'], [storyNodeSpy]); - component.classroomUrlFragment = 'math'; - component.topicUrlFragment = 'topic'; + ( + component.storySummary as jasmine.SpyObj + ).getId.and.returnValue(''); + ( + component.storySummary as jasmine.SpyObj + ).isNodeCompleted.and.returnValue(true); + urlService.getQueryFieldValuesAsList.and.callFake((fieldName: string) => { + return fieldName === 'arc_mastered' ? ['true'] : ['1']; + }); + localStorageService.getMasteredAdventures.calls.reset(); + localStorageService.updateMasteredAdventures.calls.reset(); component.ngOnInit(); - expect(component.lessonCards[0].isPublished).toBe(false); + expect(localStorageService.getMasteredAdventures).not.toHaveBeenCalled(); + expect(localStorageService.updateMasteredAdventures).not.toHaveBeenCalled(); }); - it('should return false from isChapterPublished when getStatus returns null', () => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getStatus', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getStatus.and.returnValue(null); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + it('should open arc skip confirmation as bottom sheet on mobile', fakeAsync(() => { + windowDimensionsService.getWidth.and.returnValue(300); + const mockBottomSheetRef = { + afterDismissed: () => ({subscribe: jasmine.createSpy('subscribe')}), + }; + bottomSheet.open.and.returnValue(mockBottomSheetRef); + + const storyNodeSpy1 = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null + ); + const storyNodeSpy2 = createStoryNodeSpy( + 'Node 2', + 'Desc 2', + 'exp_2', + 'node_2', + null ); - component.storySummary = createStorySummarySpy(['Node'], [storyNodeSpy]); + component.storySummary = createStorySummarySpy( + ['Node 1', 'Node 2'], + [storyNodeSpy1, storyNodeSpy2], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + { + id: 'arc_2', + title: 'Adventure 2', + description: 'Second adventure', + node_ids: ['node_2'], + }, + ] + ); component.classroomUrlFragment = 'math'; component.topicUrlFragment = 'topic'; component.ngOnInit(); - expect(component.lessonCards[0].isPublished).toBe(false); - }); + component.onNavigationLessonSelected({lessonNumber: 2, adventureIndex: 1}); - it('should return false from isChapterReadyToPublish when getStatus returns null', () => { - platformFeatureService.status.SerialChapterLaunchLearnerView.isEnabled = - true; - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getStatus', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getStatus.and.returnValue(null); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + expect(bottomSheet.open).toHaveBeenCalledWith( + ArcSkipConfirmationModalComponent, + jasmine.objectContaining({data: jasmine.any(Object)}) ); + expect(ngbModal.open).not.toHaveBeenCalled(); + + tick(300); + })); + + it('should open adventure mastered modal as bottom sheet on mobile', fakeAsync(() => { + windowDimensionsService.getWidth.and.returnValue(300); + const mockBottomSheetRef = { + afterDismissed: () => ({subscribe: jasmine.createSpy('subscribe')}), + }; + bottomSheet.open.and.returnValue(mockBottomSheetRef); + + const storyNodeSpy = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null, + {status: 'Published'} + ); + + component.storySummary = createStorySummarySpy( + ['Node 1'], + [storyNodeSpy], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + ] + ); + (component.storySummary.isNodeCompleted as jasmine.Spy).and.returnValue( + true + ); + urlService.getQueryFieldValuesAsList.and.callFake((fieldName: string) => { + if (fieldName === 'arc_mastered') { + return ['true']; + } + if (fieldName === 'arc_id') { + return ['1']; + } + return []; + }); - component.storySummary = createStorySummarySpy(['Node'], [storyNodeSpy]); component.classroomUrlFragment = 'math'; component.topicUrlFragment = 'topic'; - component.ngOnInit(); + tick(); - expect(component.lessonCards[0].lessonProgressStatus).toBe('not_started'); - }); - - it('should use longer practice description when there are multiple adventure groups', () => { - const storyNodeSpy1 = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy1.getTitle.and.returnValue('Node 1'); - storyNodeSpy1.getDescription.and.returnValue('Desc 1'); - storyNodeSpy1.getThumbnailFilename.and.returnValue(null); - storyNodeSpy1.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy1.getId.and.returnValue('node_1'); - storyNodeSpy1.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy1.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy1.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + expect(bottomSheet.open).toHaveBeenCalledWith( + AdventureMasteredModalComponent, + jasmine.objectContaining({data: jasmine.any(Object)}) ); + expect(ngbModal.open).not.toHaveBeenCalled(); + })); - const storyNodeSpy2 = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy2.getTitle.and.returnValue('Node 2'); - storyNodeSpy2.getDescription.and.returnValue('Desc 2'); - storyNodeSpy2.getThumbnailFilename.and.returnValue(null); - storyNodeSpy2.getExplorationId.and.returnValue('exp_2'); - storyNodeSpy2.getId.and.returnValue('node_2'); - storyNodeSpy2.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy2.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy2.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + it('should call onArcSkipConfirmationProceed when bottom sheet confirms', fakeAsync(() => { + windowDimensionsService.getWidth.and.returnValue(300); + let dismissCallback: (result: string) => void = () => {}; + const mockBottomSheetRef = { + afterDismissed: () => ({ + subscribe: (cb: (result: string) => void) => { + dismissCallback = cb; + }, + }), + }; + bottomSheet.open.and.returnValue(mockBottomSheetRef); + + const storyNodeSpy1 = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null + ); + const storyNodeSpy2 = createStoryNodeSpy( + 'Node 2', + 'Desc 2', + 'exp_2', + 'node_2', + null ); component.storySummary = createStorySummarySpy( @@ -2412,99 +3759,191 @@ describe('TopicStorySectionComponent', () => { ); component.classroomUrlFragment = 'math'; component.topicUrlFragment = 'topic'; - component.practiceCount = 1; component.ngOnInit(); - expect(component.isPracticeCardVisible).toBe(true); - expect(component.practiceCard.practiceDescription).toBe( - 'Test what you have learned in Adventure 1 to unlock Adventure 2.' + component.onNavigationLessonSelected({lessonNumber: 2, adventureIndex: 1}); + + dismissCallback('confirm'); + + expect(component.isAdventureSkipped(0)).toBe(true); + expect(localStorageService.updateSkippedAdventures).toHaveBeenCalledWith( + 'story_id_1', + [0] ); - }); - it('should scroll to the coming-soon lesson card when navigating to it', fakeAsync(() => { - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue(null); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + tick(300); + })); + + it('should call onArcSkipConfirmationCancel when bottom sheet is dismissed', fakeAsync(() => { + windowDimensionsService.getWidth.and.returnValue(300); + let dismissCallback: (result: string) => void = () => {}; + const mockBottomSheetRef = { + afterDismissed: () => ({ + subscribe: (cb: (result: string) => void) => { + dismissCallback = cb; + }, + }), + }; + bottomSheet.open.and.returnValue(mockBottomSheetRef); + + const storyNodeSpy1 = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null + ); + const storyNodeSpy2 = createStoryNodeSpy( + 'Node 2', + 'Desc 2', + 'exp_2', + 'node_2', + null ); - component.storySummary = createStorySummarySpy(['Node'], [storyNodeSpy]); + component.storySummary = createStorySummarySpy( + ['Node 1', 'Node 2'], + [storyNodeSpy1, storyNodeSpy2], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + { + id: 'arc_2', + title: 'Adventure 2', + description: 'Second adventure', + node_ids: ['node_2'], + }, + ] + ); component.classroomUrlFragment = 'math'; component.topicUrlFragment = 'topic'; component.ngOnInit(); - fixture.detectChanges(); - const lessonWrapper = component.lessonCardWrappers - .toArray() - .find(wrapper => wrapper.nativeElement.id === 'coming-soon-lesson-1'); - expect(lessonWrapper).toBeDefined(); - if (!lessonWrapper) { - fail('Expected coming-soon lesson wrapper to be defined'); - return; - } - spyOn(lessonWrapper.nativeElement, 'scrollIntoView'); + component.onNavigationLessonSelected({lessonNumber: 2, adventureIndex: 1}); - component.onNavigationLessonSelected(1); + dismissCallback('cancel'); + + expect(component.isAdventureSkipped(0)).toBe(false); tick(300); + })); - expect(lessonWrapper.nativeElement.scrollIntoView).toHaveBeenCalledWith({ - behavior: 'smooth', - block: 'start', + it('should call onAdventureMasteredContinue when mastered bottom sheet confirms', fakeAsync(() => { + windowDimensionsService.getWidth.and.returnValue(300); + let dismissCallback: (result: string) => void = () => {}; + const mockBottomSheetRef = { + afterDismissed: () => ({ + subscribe: (cb: (result: string) => void) => { + dismissCallback = cb; + }, + }), + }; + bottomSheet.open.and.returnValue(mockBottomSheetRef); + + const storyNodeSpy = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null, + {status: 'Published'} + ); + + component.storySummary = createStorySummarySpy( + ['Node 1'], + [storyNodeSpy], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + ] + ); + (component.storySummary.isNodeCompleted as jasmine.Spy).and.returnValue( + true + ); + urlService.getQueryFieldValuesAsList.and.callFake((fieldName: string) => { + if (fieldName === 'arc_mastered') { + return ['true']; + } + if (fieldName === 'arc_id') { + return ['1']; + } + return []; }); + + component.classroomUrlFragment = 'math'; + component.topicUrlFragment = 'topic'; + component.ngOnInit(); + tick(); + + dismissCallback('confirm'); + + expect(component.masteredAdventureIndex).toBeNull(); + expect(Reflect.get(component, 'adventureMasteredModalRef')).toBeNull(); })); - it('should return false from isChapterReadyToPublish when getStatus throws and serial flag enabled', () => { - platformFeatureService.status.SerialChapterLaunchLearnerView.isEnabled = - true; - const storyNodeSpy = jasmine.createSpyObj('StoryNode', [ - 'getTitle', - 'getDescription', - 'getThumbnailFilename', - 'getExplorationId', - 'getId', - 'getStatus', - 'getAvailableTextLanguageCodes', - 'getAvailableVoiceoverLanguageCodes', - 'getAvailableVoiceoverLanguageAccentDescriptions', - ]); - storyNodeSpy.getTitle.and.returnValue('Node'); - storyNodeSpy.getDescription.and.returnValue('Desc'); - storyNodeSpy.getThumbnailFilename.and.returnValue(null); - storyNodeSpy.getExplorationId.and.returnValue('exp_1'); - storyNodeSpy.getId.and.returnValue('node_1'); - storyNodeSpy.getStatus.and.throwError('Status error'); - storyNodeSpy.getAvailableTextLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageCodes.and.returnValue([]); - storyNodeSpy.getAvailableVoiceoverLanguageAccentDescriptions.and.returnValue( - {} + it('should clear mastered modal ref when mastered bottom sheet is dismissed', fakeAsync(() => { + windowDimensionsService.getWidth.and.returnValue(300); + let dismissCallback: (result: string) => void = () => {}; + const mockBottomSheetRef = { + afterDismissed: () => ({ + subscribe: (cb: (result: string) => void) => { + dismissCallback = cb; + }, + }), + }; + bottomSheet.open.and.returnValue(mockBottomSheetRef); + + const storyNodeSpy = createStoryNodeSpy( + 'Node 1', + 'Desc 1', + 'exp_1', + 'node_1', + null, + {status: 'Published'} ); - component.storySummary = createStorySummarySpy(['Node'], [storyNodeSpy]); + component.storySummary = createStorySummarySpy( + ['Node 1'], + [storyNodeSpy], + [ + { + id: 'arc_1', + title: 'Adventure 1', + description: 'First adventure', + node_ids: ['node_1'], + }, + ] + ); + (component.storySummary.isNodeCompleted as jasmine.Spy).and.returnValue( + true + ); + urlService.getQueryFieldValuesAsList.and.callFake((fieldName: string) => { + if (fieldName === 'arc_mastered') { + return ['true']; + } + if (fieldName === 'arc_id') { + return ['1']; + } + return []; + }); + component.classroomUrlFragment = 'math'; component.topicUrlFragment = 'topic'; - component.ngOnInit(); + tick(); - // When getStatus throws and serial flag is enabled, isComingSoon falls back to false - // (getExplorationId is set so it won't be coming_soon via the null check) - expect(component.lessonCards[0].lessonProgressStatus).toBe('not_started'); - }); + dismissCallback('cancel'); + + expect(Reflect.get(component, 'adventureMasteredModalRef')).toBeNull(); + })); }); diff --git a/core/templates/pages/topic-viewer-page/topic-story-section/topic-story-section.component.ts b/core/templates/pages/topic-viewer-page/topic-story-section/topic-story-section.component.ts index 4130576e3497b..12426ba5cdfed 100644 --- a/core/templates/pages/topic-viewer-page/topic-story-section/topic-story-section.component.ts +++ b/core/templates/pages/topic-viewer-page/topic-story-section/topic-story-section.component.ts @@ -27,9 +27,14 @@ import { SimpleChanges, ViewChildren, } from '@angular/core'; +import {MatBottomSheet} from '@angular/material/bottom-sheet'; import {Subscription} from 'rxjs'; +import {TranslateService} from '@ngx-translate/core'; + +import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; import {AppConstants} from 'app.constants'; +import {ClassroomDomainConstants} from 'domain/classroom/classroom-domain.constants'; import {StoryDomainConstants} from 'domain/story/story-domain.constants'; import {StoryNode} from 'domain/story/story-node.model'; import {StorySummary} from 'domain/story/story-summary.model'; @@ -40,15 +45,24 @@ import {AssetsBackendApiService} from 'services/assets-backend-api.service'; import {ChapterLabelVisibilityService} from 'services/chapter-label-visibility.service'; import {I18nLanguageCodeService} from 'services/i18n-language-code.service'; import {UrlService} from 'services/contextual/url.service'; +import {WindowDimensionsService} from 'services/contextual/window-dimensions.service'; import {ChapterProgressLoaderService} from 'services/chapter-progress-loader.service'; +import {LocalStorageService} from 'services/local-storage.service'; import constants from 'assets/constants'; - import './topic-story-section.component.css'; +import {AdventureMasteredModalComponent} from './adventure-mastered-modal.component'; +import {ArcSkipConfirmationModalComponent} from './arc-skip-confirmation-modal.component'; +import {AdventureNavigationLessonSelection} from './adventure-navigation.component'; +import {LessonProgressStatus} from './topic-lesson-card/topic-lesson-card.component'; + const PRIMARY_AVATAR_IMAGE_PATH = '/avatar/oppia_avatar_large_100px.svg'; const FALLBACK_AVATAR_IMAGE_PATH = '/general/collection_mascot.svg'; const FALLBACK_LESSON_THUMBNAIL_PATH = '/splash/student_desk1x.webp'; +const ARC_MASTERED_QUERY_PARAM = 'arc_mastered'; +const ARC_ID_QUERY_PARAM = 'arc_id'; +const MOBILE_SCREEN_BREAKPOINT = 480; interface LessonCardData { lessonNumber: number; @@ -58,11 +72,7 @@ interface LessonCardData { startUrl: string; practiceUrl: string; nodeId: string; - lessonProgressStatus: - | 'not_started' - | 'in_progress' - | 'completed' - | 'coming_soon'; + lessonProgressStatus: LessonProgressStatus; totalCheckpointsCount: number; visitedCheckpointsCount: number; isComingSoon: boolean; @@ -95,9 +105,12 @@ interface PracticeCardData { interface AdventureNavigationGroupData { lessons: { lessonNumber: number; + isCompleted: boolean; }[]; accentColor: string; showPractice: boolean; + isPracticeCompleted: boolean; + arcId: string; } @Component({ @@ -117,6 +130,10 @@ export class TopicStorySectionComponent @Input() practiceCount: number = 0; @Input() lessonCount: number = 0; + // True when this section is rendered inside the topic editor's preview tab. + // Used to offset the sticky adventure navigation below the editor's fixed + // header bar. + @Input() isInTopicEditorPreview: boolean = false; oppiaAvatarImageUrl: string = ''; studyGuideUrl: string = '#'; @@ -138,12 +155,23 @@ export class TopicStorySectionComponent masteryChallengeUrl: string = '#'; isPracticeCardVisible: boolean = false; _expandedAdventureIndices: Set = new Set(); + skippedAdventureIndices: Set = new Set(); navigatedLessonNumber: number | null = null; - - @ViewChildren('lessonCardWrapper') - lessonCardWrappers!: QueryList>; - @ViewChildren('practiceCardWrapper') - practiceCardWrappers!: QueryList>; + showArcSkipConfirmationModal: boolean = false; + showAdventureMasteredModal: boolean = false; + masteredAdventureIndex: number | null = null; + pendingArcSkipTargetLabel: string = ''; + private pendingNavigationLessonNumber: number | null = null; + private pendingNavigationAdventureIndex: number | null = null; + private completedAdventurePracticeArcIds: Set = new Set(); + private hasHandledArcMasteredQueryParams: boolean = false; + private arcSkipModalRef: NgbModalRef | null = null; + private adventureMasteredModalRef: NgbModalRef | null = null; + + @ViewChildren('lessonWrapper') + lessonWrappers!: QueryList>; + @ViewChildren('practiceWrapper') + practiceWrappers!: QueryList>; private directiveSubscriptions: Subscription = new Subscription(); @@ -156,36 +184,394 @@ export class TopicStorySectionComponent this._expandedAdventureIndices.delete(index); } else { this._expandedAdventureIndices.add(index); + if (this.skippedAdventureIndices.delete(index)) { + this.persistSkippedAdventures(); + } + } + } + + onNavigationLessonSelected( + selection: AdventureNavigationLessonSelection + ): void { + const lessonNumber = selection.lessonNumber; + const adventureIndex = selection.adventureIndex; + + if (adventureIndex !== -1 && this.shouldConfirmArcSkip(adventureIndex)) { + this.pendingNavigationLessonNumber = lessonNumber; + this.pendingNavigationAdventureIndex = adventureIndex; + this.pendingArcSkipTargetLabel = this.translateService.instant( + 'I18N_TOPIC_VIEWER_ADVENTURE_NUMBER_LABEL', + {adventureNumber: adventureIndex + 1} + ); + this.openArcSkipConfirmationModal(); + return; + } + + this.selectLessonFromNavigation(lessonNumber, adventureIndex); + } + + onArcSkipConfirmationCancel(): void { + this.arcSkipModalRef = null; + this.pendingNavigationLessonNumber = null; + this.pendingNavigationAdventureIndex = null; + this.pendingArcSkipTargetLabel = ''; + } + + onArcSkipConfirmationProceed(): void { + if ( + this.pendingNavigationLessonNumber === null || + this.pendingNavigationAdventureIndex === null + ) { + this.onArcSkipConfirmationCancel(); + return; + } + + this.arcSkipModalRef = null; + this.selectLessonFromNavigation( + this.pendingNavigationLessonNumber, + this.pendingNavigationAdventureIndex + ); + + this.pendingNavigationLessonNumber = null; + this.pendingNavigationAdventureIndex = null; + this.pendingArcSkipTargetLabel = ''; + } + + private openArcSkipConfirmationModal(): void { + if (this.windowDimensionsService.getWidth() < MOBILE_SCREEN_BREAKPOINT) { + const bottomSheetRef = this.bottomSheet.open( + ArcSkipConfirmationModalComponent, + { + data: { + adventureLabel: this.pendingArcSkipTargetLabel, + confirmationMessage: this.getArcSkipConfirmationMessage(), + }, + } + ); + bottomSheetRef.afterDismissed().subscribe((result: string) => { + if (result === 'confirm') { + this.onArcSkipConfirmationProceed(); + } else { + this.onArcSkipConfirmationCancel(); + } + }); + } else { + this.arcSkipModalRef = this.ngbModal.open( + ArcSkipConfirmationModalComponent, + { + backdrop: 'static', + windowClass: 'oppia-arc-skip-confirmation-modal', + } + ); + this.arcSkipModalRef.componentInstance.adventureLabel = + this.pendingArcSkipTargetLabel; + this.arcSkipModalRef.componentInstance.confirmationMessage = + this.getArcSkipConfirmationMessage(); + this.arcSkipModalRef.result.then( + () => this.onArcSkipConfirmationProceed(), + () => this.onArcSkipConfirmationCancel() + ); + } + } + + private openAdventureMasteredModal(): void { + if (this.windowDimensionsService.getWidth() < MOBILE_SCREEN_BREAKPOINT) { + const bottomSheetRef = this.bottomSheet.open( + AdventureMasteredModalComponent, + { + data: { + title: this.getAdventureMasteredTitle(), + message: this.getAdventureMasteredSubtitle(), + }, + } + ); + bottomSheetRef.afterDismissed().subscribe((result: string) => { + if (result === 'confirm') { + this.onAdventureMasteredContinue(); + } else { + this.adventureMasteredModalRef = null; + } + }); + } else { + this.adventureMasteredModalRef = this.ngbModal.open( + AdventureMasteredModalComponent, + { + backdrop: 'static', + windowClass: 'oppia-adventure-mastered-modal', + } + ); + this.adventureMasteredModalRef.componentInstance.title = + this.getAdventureMasteredTitle(); + this.adventureMasteredModalRef.componentInstance.message = + this.getAdventureMasteredSubtitle(); + this.adventureMasteredModalRef.result.then( + () => this.onAdventureMasteredContinue(), + () => { + this.adventureMasteredModalRef = null; + } + ); + } + } + + onAdventureMasteredContinue(): void { + if (this.masteredAdventureIndex !== null) { + this._expandedAdventureIndices.delete(this.masteredAdventureIndex); + + const nextAdventureIndex = this.masteredAdventureIndex + 1; + if (nextAdventureIndex < this.visibleAdventureGroups.length) { + this._expandedAdventureIndices.add(nextAdventureIndex); + const nextAdventureFirstLesson = + this.visibleAdventureGroups[nextAdventureIndex].lessonCards[0]; + if (nextAdventureFirstLesson) { + this.activeLessonNumber = nextAdventureFirstLesson.lessonNumber; + this.navigatedLessonNumber = nextAdventureFirstLesson.lessonNumber; + } + } + } + this.adventureMasteredModalRef = null; + this.masteredAdventureIndex = null; + this.hasHandledArcMasteredQueryParams = true; + } + + getAdventureMasteredTitle(): string { + if (this.masteredAdventureIndex === null) { + return this.translateService.instant( + 'I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_TITLE' + ); + } + return this.translateService.instant( + 'I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_NUMBER_TITLE', + {adventureNumber: this.masteredAdventureIndex + 1} + ); + } + + getAdventureMasteredSubtitle(): string { + if (this.masteredAdventureIndex === null) { + return this.translateService.instant( + 'I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_MOMENTUM_SUBTITLE' + ); + } + + const unlockedAdventureNumber = this.masteredAdventureIndex + 2; + if (unlockedAdventureNumber <= this.visibleAdventureGroups.length) { + return this.translateService.instant( + 'I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_UNLOCKED_SUBTITLE', + {adventureNumber: unlockedAdventureNumber} + ); + } + + return this.translateService.instant( + 'I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_ALL_COMPLETE_SUBTITLE' + ); + } + + getArcSkipConfirmationMessage(): string { + if (this.pendingNavigationAdventureIndex === null) { + return ''; + } + + const skippedAdventureNumbers: number[] = []; + for (let index = 0; index < this.pendingNavigationAdventureIndex; index++) { + if (!this.isAdventureCompleted(index)) { + skippedAdventureNumbers.push(index + 1); + } + } + + if (skippedAdventureNumbers.length === 0) { + return ''; + } + + return this.translateService.instant( + 'I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_MESSAGE', + { + count: skippedAdventureNumbers.length, + adventureNumbers: this.joinAdventureNumbers(skippedAdventureNumbers), + messageFormat: true, + } + ); + } + + private joinAdventureNumbers(numbers: number[]): string { + if (numbers.length === 1) { + return String(numbers[0]); + } + if (numbers.length === 2) { + return numbers.join(' and '); + } + return ( + numbers.slice(0, -1).join(', ') + ', and ' + numbers[numbers.length - 1] + ); + } + + isAdventureSkipped(adventureIndex: number): boolean { + return this.skippedAdventureIndices.has(adventureIndex); + } + + getSkippedAdventureButtonLabel(adventureIndex: number): string { + const adventureGroup = this.visibleAdventureGroups[adventureIndex]; + if (!adventureGroup) { + return this.translateService.instant( + 'I18N_TOPIC_VIEWER_ADVENTURE_START_BUTTON' + ); + } + + const hasStarted = adventureGroup.lessonCards.some( + card => card.lessonProgressStatus !== 'not_started' + ); + return hasStarted + ? this.translateService.instant( + 'I18N_TOPIC_VIEWER_ADVENTURE_RESUME_BUTTON' + ) + : this.translateService.instant( + 'I18N_TOPIC_VIEWER_ADVENTURE_START_BUTTON' + ); + } + + isAdventureCompleted(adventureIndex: number): boolean { + const adventureGroup = this.visibleAdventureGroups[adventureIndex]; + if (!adventureGroup || adventureGroup.lessonCards.length === 0) { + return false; + } + + return this.areAllLessonsCompleted(adventureIndex); + } + + areAllLessonsCompleted(adventureIndex: number): boolean { + const adventureGroup = this.visibleAdventureGroups[adventureIndex]; + if (!adventureGroup || adventureGroup.lessonCards.length === 0) { + return false; + } + + return adventureGroup.lessonCards.every( + card => card.lessonProgressStatus === 'completed' + ); + } + + isAdventurePracticeCompleted(adventureIndex: number): boolean { + const adventureGroup = this.visibleAdventureGroups[adventureIndex]; + if (!adventureGroup) { + return false; } + return this.completedAdventurePracticeArcIds.has(adventureGroup.arcId); } - onNavigationLessonSelected(lessonNumber: number): void { + isStoryCompleted(): boolean { + if (this.availableLessonCards.length === 0) { + return false; + } + + return this.availableLessonCards.every( + card => card.lessonProgressStatus === 'completed' + ); + } + + private selectLessonFromNavigation( + lessonNumber: number, + adventureIndex: number + ): void { this.activeLessonNumber = lessonNumber; this.navigatedLessonNumber = lessonNumber; - // Expand the adventure that contains this lesson. - const adventureIndex = this.visibleAdventureGroups.findIndex(group => - group.lessonCards.some(card => card.lessonNumber === lessonNumber) - ); if (adventureIndex !== -1) { + this.markSkippedAdventuresBefore(adventureIndex); this._expandedAdventureIndices.add(adventureIndex); } // Scroll to the lesson card after Angular finishes updating the DOM. setTimeout(() => { - const lessonCardElement = this.getLessonCardElement(lessonNumber); - if (lessonCardElement) { - lessonCardElement.scrollIntoView({behavior: 'smooth', block: 'start'}); + const lessonEl = this.lessonWrappers?.find(ref => { + const elId = ref.nativeElement.id; + return ( + elId === 'lesson-' + lessonNumber || + elId === 'coming-soon-lesson-' + lessonNumber + ); + }); + if (lessonEl) { + lessonEl.nativeElement.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); } }, 300); } - onNavigationPracticeSelected(adventureIndex: number): void { + private shouldConfirmArcSkip(targetAdventureIndex: number): boolean { + if (targetAdventureIndex <= 0) { + return false; + } + + for (let index = 0; index < targetAdventureIndex; index++) { + if (!this.isAdventureCompleted(index)) { + return true; + } + } + return false; + } + + private markSkippedAdventuresBefore(targetAdventureIndex: number): void { + if (targetAdventureIndex <= 0) { + return; + } + + for (let index = 0; index < targetAdventureIndex; index++) { + if (!this.isAdventureCompleted(index)) { + this.skippedAdventureIndices.add(index); + this._expandedAdventureIndices.delete(index); + } + } + this.persistSkippedAdventures(); + } + + private restoreSkippedAdventures(): void { + const storyId = this.storySummary.getId(); + if (!storyId) { + return; + } + this.skippedAdventureIndices = new Set( + this.localStorageService.getSkippedAdventures(storyId) + ); + } + + private persistSkippedAdventures(): void { + const storyId = this.storySummary.getId(); + if (!storyId) { + return; + } + this.localStorageService.updateSkippedAdventures( + storyId, + Array.from(this.skippedAdventureIndices) + ); + } + + private restoreMasteredAdventures(): void { + const storyId = this.storySummary.getId(); + if (!storyId) { + return; + } + this.completedAdventurePracticeArcIds = new Set( + this.localStorageService.getMasteredAdventures(storyId) + ); + } + + private persistMasteredAdventures(): void { + const storyId = this.storySummary.getId(); + if (!storyId) { + return; + } + this.localStorageService.updateMasteredAdventures( + storyId, + Array.from(this.completedAdventurePracticeArcIds) + ); + } + + onNavigationPracticeSelected(arcId: string): void { // Scroll to the practice card of the specific adventure after Angular finishes updating the DOM. setTimeout(() => { - const practiceCardElement = this.getPracticeCardElement(adventureIndex); - if (practiceCardElement) { - practiceCardElement.scrollIntoView({ + const practiceEl = this.practiceWrappers?.find( + ref => ref.nativeElement.id === 'practice-card-' + arcId + ); + if (practiceEl) { + practiceEl.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start', }); @@ -193,26 +579,6 @@ export class TopicStorySectionComponent }, 300); } - private getLessonCardElement(lessonNumber: number): HTMLElement | null { - const wrappers = this.lessonCardWrappers?.toArray() ?? []; - const match = wrappers.find(wrapper => { - const id = wrapper.nativeElement.id; - return ( - id === 'lesson-' + lessonNumber || - id === 'coming-soon-lesson-' + lessonNumber - ); - }); - return match ? match.nativeElement : null; - } - - private getPracticeCardElement(adventureIndex: number): HTMLElement | null { - const wrappers = this.practiceCardWrappers?.toArray() ?? []; - const match = wrappers.find(wrapper => { - return wrapper.nativeElement.id === 'practice-card-' + adventureIndex; - }); - return match ? match.nativeElement : null; - } - constructor( private assetsBackendApiService: AssetsBackendApiService, private urlInterpolationService: UrlInterpolationService, @@ -220,7 +586,12 @@ export class TopicStorySectionComponent private i18nLanguageCodeService: I18nLanguageCodeService, private chapterProgressLoaderService: ChapterProgressLoaderService, private topicSessionFallbackLanguageService: TopicSessionFallbackLanguageService, - private chapterLabelVisibilityService: ChapterLabelVisibilityService + private chapterLabelVisibilityService: ChapterLabelVisibilityService, + private localStorageService: LocalStorageService, + private ngbModal: NgbModal, + private bottomSheet: MatBottomSheet, + private windowDimensionsService: WindowDimensionsService, + private translateService: TranslateService ) {} ngOnInit(): void { @@ -300,9 +671,7 @@ export class TopicStorySectionComponent return this.i18nLanguageCodeService.isCurrentLanguageRTL(); } - private getLessonProgressStatus( - node: StoryNode - ): 'not_started' | 'in_progress' | 'completed' | 'coming_soon' { + private getLessonProgressStatus(node: StoryNode): LessonProgressStatus { if (this.isChapterDisplayedAsComingSoon(node) || !node.getExplorationId()) { return 'coming_soon'; } @@ -391,7 +760,9 @@ export class TopicStorySectionComponent const allNodes = this.storySummary.getAllNodes(); this.adventureGroups = this.buildAdventureGroups(allNodes); + this.restoreSkippedAdventures(); this.updateVisibleSections(); + this.maybeShowAdventureMasteredModal(); } private buildAdventureGroups(allNodes: StoryNode[]): AdventureGroupData[] { @@ -414,7 +785,9 @@ export class TopicStorySectionComponent adventureLessonCards.push(this.lessonCards[nodeIndex]); } }); - const arcNumber = arc.id.split('_').pop() || ''; + // The backend maps an arc to its 1-based position among the topic's + // story arcs, so pass the position rather than a parsed arc id. + const arcId = String(adventureIndex + 1); return { adventureTitle: arc.title, adventureDescription: arc.description, @@ -423,7 +796,7 @@ export class TopicStorySectionComponent iconBg: paletteColor.iconBg, headerBackgroundColor: paletteColor.headerBg, headerBorderColor: paletteColor.headerBorder, - arcId: arcNumber, + arcId, }; }); } @@ -488,8 +861,11 @@ export class TopicStorySectionComponent }); this.adventureGroups = this.buildAdventureGroups(allNodes); + this.restoreSkippedAdventures(); + this.restoreMasteredAdventures(); this.updateVisibleSections(); this.activeLessonNumber = this.getActiveLessonNumber(); + this.maybeShowAdventureMasteredModal(); this.isPracticeCardVisible = this.practiceCount >= 1; this.practiceCard = this.getPracticeCardData(); @@ -512,6 +888,19 @@ export class TopicStorySectionComponent }; } + getPracticeTitle(adventureIndex: number): string { + return `Adventure ${adventureIndex + 1} Review & Test`; + } + + getPracticeDescription(adventureIndex: number): string { + const adventureNumber = adventureIndex + 1; + const nextAdventureNumber = adventureNumber + 1; + if (adventureIndex < this.visibleAdventureGroups.length - 1) { + return `Test what you have learned in Adventure ${adventureNumber} to unlock Adventure ${nextAdventureNumber}.`; + } + return `Test what you have learned in Adventure ${adventureNumber}.`; + } + getLessonPracticeUrl(nodeId: string): string { if (!this.classroomUrlFragment || !this.topicUrlFragment) { return '#'; @@ -635,10 +1024,15 @@ export class TopicStorySectionComponent lessons: visibleLessons.map(card => { return { lessonNumber: card.lessonNumber, + isCompleted: card.lessonProgressStatus === 'completed', }; }), accentColor: group.accentColor, showPractice: visibleLessons.length > 0, + isPracticeCompleted: + visibleLessons.length > 0 && + this.completedAdventurePracticeArcIds.has(group.arcId), + arcId: group.arcId, }; }) .filter(group => group.lessons.length > 0); @@ -647,8 +1041,72 @@ export class TopicStorySectionComponent this.visibleAdventureGroups.length && this._expandedAdventureIndices.size === 0 ) { - this._expandedAdventureIndices = new Set([0]); + // Do not auto-expand an adventure that was previously skipped; expand + // the first adventure that was not skipped instead. + const firstNonSkippedIndex = this.visibleAdventureGroups.findIndex( + (_group, index) => !this.skippedAdventureIndices.has(index) + ); + this._expandedAdventureIndices = new Set([ + firstNonSkippedIndex === -1 ? 0 : firstNonSkippedIndex, + ]); + } + } + + private maybeShowAdventureMasteredModal(): void { + if ( + this.adventureMasteredModalRef !== null || + this.hasHandledArcMasteredQueryParams + ) { + return; + } + + const arcMasteredFlagValues = this.urlService.getQueryFieldValuesAsList( + ARC_MASTERED_QUERY_PARAM + ); + const arcIdValues = + this.urlService.getQueryFieldValuesAsList(ARC_ID_QUERY_PARAM); + + if ( + arcMasteredFlagValues.length === 0 || + arcMasteredFlagValues[0] !== 'true' || + arcIdValues.length === 0 + ) { + return; + } + + const masteredArcId = this.normalizeArcIdFromQueryValue(arcIdValues[0]); + if (!masteredArcId) { + return; + } + + const adventureIndex = this.visibleAdventureGroups.findIndex( + group => group.arcId === masteredArcId + ); + + if (adventureIndex === -1 || !this.areAllLessonsCompleted(adventureIndex)) { + return; } + + this.completedAdventurePracticeArcIds.add(masteredArcId); + this.persistMasteredAdventures(); + this.updateVisibleSections(); + + this.hasHandledArcMasteredQueryParams = true; + this.masteredAdventureIndex = adventureIndex; + this.openAdventureMasteredModal(); + } + + private normalizeArcIdFromQueryValue(rawArcId: string): string | null { + if (!rawArcId) { + return null; + } + + const match = rawArcId.match(/^\d+/); + if (!match) { + return null; + } + + return match[0]; } private isChapterReadyToPublish(node: StoryNode): boolean { @@ -748,7 +1206,16 @@ export class TopicStorySectionComponent } private getStudyGuideUrl(): string { - return this.urlService.getLearnerTopicStudyGuideUrl(); + if (!this.classroomUrlFragment || !this.topicUrlFragment) { + return '#'; + } + return ClassroomDomainConstants.TOPIC_VIEWER_STUDYGUIDE_URL_TEMPLATE.replace( + '', + encodeURIComponent(this.classroomUrlFragment) + ).replace( + '', + encodeURIComponent(this.topicUrlFragment) + ); } private getPrimaryAvatarImageUrl(): string { diff --git a/core/templates/pages/topic-viewer-page/topic-viewer-content/topic-viewer-content.component.css b/core/templates/pages/topic-viewer-page/topic-viewer-content/topic-viewer-content.component.css new file mode 100644 index 0000000000000..50ebf2025595d --- /dev/null +++ b/core/templates/pages/topic-viewer-page/topic-viewer-content/topic-viewer-content.component.css @@ -0,0 +1,143 @@ +topic-viewer-content .oppia-redesigned-topic-viewer-container { + background-color: #fff; + min-height: 100vh; + text-align: start; +} + +topic-viewer-content .redesigned-topic-viewer-content { + padding: 24px 36px 0; +} + +topic-viewer-content .redesigned-topic-viewer-content topic-header { + display: block; + margin: 0 auto; + max-width: 960px; + width: 100%; +} + +topic-viewer-content .oppia-redesigned-topic-viewer-container.studyguide-view-active .redesigned-topic-viewer-content topic-header { + max-width: 1160px; +} + +topic-viewer-content .oppia-redesigned-topic-viewer-container.studyguide-view-active .redesigned-topic-viewer-content { + background-color: #f4f6f7; + background-image: url('/assets/images/background/conversation_skin.svg'); + background-position: top center; + background-repeat: repeat; + background-size: 1024px auto; +} + +topic-viewer-content .redesigned-stories-section { + background-color: #dceaf4; + background-image: url('/assets/images/background/bannerE.svg'); + background-position: top center; + background-repeat: repeat; + background-size: 1480px auto; + margin-top: 24px; + padding: 28px 36px 56px; +} + +topic-viewer-content .redesigned-stories-section .oppia-page-card { + border-radius: 0.25em; + margin: 0 auto; + max-width: 1160px; + width: 100%; +} + +topic-viewer-content .redesigned-secondary-section { + padding-top: 24px; +} + +topic-viewer-content .redesigned-studyguide-section { + background-color: #f4f6f7; + background-image: url('/assets/images/background/conversation_skin.svg'); + background-position: top center; + background-repeat: repeat; + background-size: 1024px auto; + margin-top: 0; + min-height: calc(100vh - 64px); + padding: 24px 36px 56px; +} + +topic-viewer-content .redesigned-stories-list { + display: flex; + flex-direction: column; + gap: 34px; + margin: 0 auto; + max-width: 1160px; + width: 100%; +} + +topic-viewer-content .coming-soon-title { + font-family: Capriola, Roboto, Arial, sans-serif; + font-size: 1.5em; + margin: 1em; + padding-top: 1em; +} + +topic-viewer-content .coming-soon-text { + font-family: Roboto, Arial, sans-serif; + font-size: 1.1em; + margin-left: 1.4em; +} + +@media(max-width: 1024px) { + topic-viewer-content .redesigned-topic-viewer-content { + padding: 24px 28px 0; + } + + topic-viewer-content .redesigned-studyguide-section { + padding: 22px 28px 48px; + } + + topic-viewer-content .redesigned-stories-section { + padding: 26px 28px 52px; + } + + topic-viewer-content .redesigned-stories-list { + max-width: 100%; + } +} + +@media(max-width: 768px) { + topic-viewer-content .redesigned-topic-viewer-content { + padding: 16px 16px 0; + } + + topic-viewer-content .redesigned-studyguide-section { + padding: 16px 16px 36px; + } + + topic-viewer-content .redesigned-stories-section { + margin-top: 16px; + padding: 20px 16px 36px; + } + + topic-viewer-content .redesigned-stories-list { + gap: 22px; + max-width: 100%; + } + + topic-viewer-content .redesigned-secondary-section { + padding-top: 14px; + } +} + +@media(max-width: 500px) { + topic-viewer-content .redesigned-topic-viewer-content { + padding: 16px 12px 0; + } + + topic-viewer-content .redesigned-studyguide-section { + padding: 16px 12px 34px; + } + + topic-viewer-content .redesigned-stories-section { + margin-top: 16px; + padding: 18px 12px 34px; + } + + topic-viewer-content .redesigned-stories-list { + gap: 22px; + } +} diff --git a/core/templates/pages/topic-viewer-page/topic-viewer-content/topic-viewer-content.component.html b/core/templates/pages/topic-viewer-page/topic-viewer-content/topic-viewer-content.component.html new file mode 100644 index 0000000000000..0fac07bfce240 --- /dev/null +++ b/core/templates/pages/topic-viewer-page/topic-viewer-content/topic-viewer-content.component.html @@ -0,0 +1,57 @@ +
+
+ + +
+
+

+ {{ 'I18N_TOPIC_VIEWER_STORIES_HEAD' | translate }} +

+
+ + +
+
+
+ +

+

+
+
+
+
+
+
+ + +
+
+
diff --git a/core/templates/pages/topic-viewer-page/topic-viewer-content/topic-viewer-content.component.spec.ts b/core/templates/pages/topic-viewer-page/topic-viewer-content/topic-viewer-content.component.spec.ts new file mode 100644 index 0000000000000..6d42ae972b0b0 --- /dev/null +++ b/core/templates/pages/topic-viewer-page/topic-viewer-content/topic-viewer-content.component.spec.ts @@ -0,0 +1,82 @@ +// 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 Unit tests for TopicViewerContentComponent. + */ + +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; + +import {StorySummary} from 'domain/story/story-summary.model'; +import {MockTranslatePipe} from 'tests/unit-test-utils'; +import { + TopicViewerContentComponent, + TopicViewerStorySectionData, +} from './topic-viewer-content.component'; + +describe('TopicViewerContentComponent', () => { + let component: TopicViewerContentComponent; + let fixture: ComponentFixture; + let storySectionData: TopicViewerStorySectionData; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [TopicViewerContentComponent, MockTranslatePipe], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TopicViewerContentComponent); + component = fixture.componentInstance; + storySectionData = { + storyId: 'story_1', + storyTitle: 'Story 1', + storyDescription: 'Description', + lessonCount: 3, + practiceCount: 2, + storySummary: StorySummary.createFromBackendDict({ + id: 'story_1', + title: 'Story 1', + node_titles: [], + thumbnail_filename: '', + thumbnail_bg_color: '', + description: 'Description', + story_is_published: true, + completed_node_titles: [], + url_fragment: 'story-1', + all_node_dicts: [], + }), + practiceSubtopicIds: [], + classroomUrlFragment: '', + topicUrlFragment: '', + }; + }); + + it('should create', () => { + expect(component).toBeDefined(); + }); + + it('should track story section data by story id', () => { + expect(component.trackStoryDataById(0, storySectionData)).toBe('story_1'); + }); + + it('should initialize with story view defaults', () => { + expect(component.activeView).toBe(component.VIEW_NAMES.STORY); + expect(component.canonicalStorySectionData).toEqual([]); + expect(component.topicIsLoading).toBeFalse(); + expect(component.isInTopicEditorPreview).toBeFalse(); + }); +}); diff --git a/core/templates/pages/topic-viewer-page/topic-viewer-content/topic-viewer-content.component.ts b/core/templates/pages/topic-viewer-page/topic-viewer-content/topic-viewer-content.component.ts new file mode 100644 index 0000000000000..fb680bf8d81cc --- /dev/null +++ b/core/templates/pages/topic-viewer-page/topic-viewer-content/topic-viewer-content.component.ts @@ -0,0 +1,73 @@ +// 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 Shared redesigned content of the topic viewer page, used by + * both the learner-facing topic viewer page and the topic editor preview tab. + */ + +import {Component, Input} from '@angular/core'; +import {StorySummary} from 'domain/story/story-summary.model'; +import {Subtopic} from 'domain/topic/subtopic.model'; + +import './topic-viewer-content.component.css'; + +export interface TopicViewerStorySectionData { + storyId: string; + storyTitle: string; + storyDescription: string; + storySummary: StorySummary; + practiceSubtopicIds: number[]; + classroomUrlFragment: string; + topicUrlFragment: string; + lessonCount: number; + practiceCount: number; +} + +@Component({ + selector: 'topic-viewer-content', + templateUrl: './topic-viewer-content.component.html', + styleUrls: ['./topic-viewer-content.component.css'], +}) +export class TopicViewerContentComponent { + readonly VIEW_NAMES = { + STORY: 'story', + STUDYGUIDE: 'studyguide', + } as const; + + // These properties below are initialized using Angular lifecycle hooks + // where we need to do non-null assertion. For more information see + // https://github.com/oppia/oppia/wiki/Guide-on-defining-types#ts-7-1 + @Input() topicName!: string; + @Input() topicDescription!: string; + @Input() topicId!: string; + @Input() classroomName!: string | null; + @Input() classroomUrlFragment: string = ''; + @Input() topicUrlFragment: string = ''; + @Input() canonicalStorySectionData: readonly TopicViewerStorySectionData[] = + []; + @Input() activeView: string = this.VIEW_NAMES.STORY; + @Input() topicIsLoading: boolean = false; + @Input() subtopics: Subtopic[] = []; + // True when this content is rendered inside the topic editor's preview tab, + // where the editor's fixed header bar adds height to the header stack. + @Input() isInTopicEditorPreview: boolean = false; + + trackStoryDataById( + index: number, + storyData: TopicViewerStorySectionData + ): string { + return storyData.storyId; + } +} diff --git a/core/templates/pages/topic-viewer-page/topic-viewer-page.component.css b/core/templates/pages/topic-viewer-page/topic-viewer-page.component.css index a92b91240da69..7a24bd825efe5 100644 --- a/core/templates/pages/topic-viewer-page/topic-viewer-page.component.css +++ b/core/templates/pages/topic-viewer-page/topic-viewer-page.component.css @@ -4,49 +4,6 @@ https://rtlcss.com/learn/usage-guide/control-directives . */ -topic-viewer-page .oppia-redesigned-topic-viewer-container { - background-color: #fff; - min-height: 100vh; - text-align: start; -} - -topic-viewer-page .redesigned-topic-viewer-content { - padding: 24px 36px 0; -} - -topic-viewer-page .oppia-redesigned-topic-viewer-container.studyguide-view-active .redesigned-topic-viewer-content { - background-color: #f4f6f7; - background-image: url('/assets/images/background/conversation_skin.svg'); - background-position: top center; - background-repeat: repeat; - background-size: 1024px auto; -} - -topic-viewer-page .redesigned-topic-viewer-content topic-header { - display: block; - margin: 0 auto; - max-width: 960px; - width: 100%; -} - -topic-viewer-page .oppia-redesigned-topic-viewer-container.studyguide-view-active .redesigned-topic-viewer-content topic-header { - max-width: 1160px; -} - -topic-viewer-page .redesigned-stories-section { - background-color: #dceaf4; - background-image: url('/assets/images/background/bannerE.svg'); - background-position: top center; - background-repeat: repeat; - background-size: 1480px auto; - margin-top: 24px; - padding: 28px 36px 56px; -} - -topic-viewer-page .redesigned-secondary-section { - padding-top: 24px; -} - topic-viewer-page .topic-viewer-page-content .oppia-page-card { border-radius: 0.25em; margin-top: 10em; @@ -54,98 +11,12 @@ topic-viewer-page .topic-viewer-page-content .oppia-page-card { width: 704px; } -topic-viewer-page .redesigned-secondary-section .oppia-page-card { - margin: 0 auto; - max-width: 1160px; - width: 100%; -} - -topic-viewer-page .redesigned-secondary-section subtopics-list .oppia-page-card, -topic-viewer-page .redesigned-secondary-section practice-tab .oppia-page-card { - margin-top: 0; -} - -topic-viewer-page .redesigned-secondary-section subtopics-list .oppia-page-card.oppia-long-text, -topic-viewer-page .redesigned-secondary-section practice-tab .oppia-page-card { - margin-left: auto; - margin-right: auto; - width: 100%; -} - topic-viewer-page .topic-viewer-page-content .tab-title { color: #017a70; font-size: 2em; overflow-wrap: break-word; } - -topic-viewer-page .redesigned-studyguide-section { - background-color: #f4f6f7; - background-image: url('/assets/images/background/conversation_skin.svg'); - background-position: top center; - background-repeat: repeat; - background-size: 1024px auto; - margin-top: 0; - min-height: calc(100vh - 64px); - padding: 24px 36px 56px; -} - -topic-viewer-page .redesigned-studyguide-section subtopics-list .oppia-subtopics-list-container { - margin: 0 auto; - max-width: 1160px; - width: 100%; -} - -topic-viewer-page .redesigned-studyguide-section subtopics-list .oppia-page-card, -topic-viewer-page .redesigned-studyguide-section subtopics-list .oppia-page-card.oppia-long-text { - background: transparent; - border: 0; - border-radius: 0; - box-shadow: none; - margin: 0; - max-width: 100%; - padding: 0; - width: 100%; -} - -topic-viewer-page .redesigned-studyguide-section subtopics-list .tab-title { - color: #00645c; - font-family: Capriola, Roboto, Arial, sans-serif; - font-size: 32px; - font-weight: 400; - line-height: 1.2; - margin: 0 0 8px; -} - -topic-viewer-page .redesigned-studyguide-section subtopics-list .subtopic-subheading { - color: #4a4a4a; - font-family: Roboto, Arial, sans-serif; - font-size: 14px; - line-height: 1.35; - margin-bottom: 24px; -} - -topic-viewer-page .redesigned-studyguide-section subtopics-list .subtopics-container { - display: grid; - gap: 28px; - grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); - justify-content: start; - margin-top: 0; -} - -topic-viewer-page .redesigned-studyguide-section subtopics-list .subtopic-summary-tile { - margin-right: 0; -} - -topic-viewer-page .redesigned-stories-list { - display: flex; - flex-direction: column; - gap: 34px; - margin: 0 auto; - max-width: 1160px; - width: 100%; -} - topic-viewer-page .oppia-topic-viewer-container { color: black; margin: 0 auto; @@ -282,30 +153,6 @@ topic-viewer-page-root .topic-viewer-page-content .coming-soon-text { } @media(max-width: 1024px) { - topic-viewer-page .redesigned-topic-viewer-content { - padding: 24px 28px 0; - } - - topic-viewer-page .redesigned-studyguide-section { - padding: 22px 28px 48px; - } - - topic-viewer-page .redesigned-studyguide-section subtopics-list .tab-title { - font-size: 34px; - } - - topic-viewer-page .redesigned-studyguide-section subtopics-list .subtopic-subheading { - font-size: 18px; - } - - topic-viewer-page .redesigned-stories-section { - padding: 26px 28px 52px; - } - - topic-viewer-page .redesigned-stories-list { - max-width: 100%; - } - topic-viewer-page .topic-viewer-tabs-list { width: 80vw; } @@ -325,77 +172,7 @@ topic-viewer-page-root .topic-viewer-page-content .coming-soon-text { } } -@media(max-width: 768px) { - topic-viewer-page .redesigned-topic-viewer-content { - padding: 16px 16px 0; - } - - topic-viewer-page .redesigned-studyguide-section { - padding: 16px 16px 36px; - } - - topic-viewer-page .redesigned-studyguide-section subtopics-list .tab-title { - font-size: 28px; - } - - topic-viewer-page .redesigned-studyguide-section subtopics-list .subtopic-subheading { - font-size: 16px; - margin-bottom: 18px; - } - - topic-viewer-page .redesigned-studyguide-section subtopics-list .subtopics-container { - gap: 18px; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - } - - topic-viewer-page .redesigned-stories-section { - margin-top: 16px; - padding: 20px 16px 36px; - } - - topic-viewer-page .redesigned-stories-list { - gap: 22px; - max-width: 100%; - } - - topic-viewer-page .redesigned-secondary-section { - padding-top: 14px; - } -} - @media(max-width: 500px) { - topic-viewer-page .redesigned-topic-viewer-content { - padding: 16px 12px 0; - } - - topic-viewer-page .redesigned-studyguide-section { - padding: 16px 12px 34px; - } - - topic-viewer-page .redesigned-studyguide-section subtopics-list .tab-title { - font-size: 25px; - line-height: 1.25; - } - - topic-viewer-page .redesigned-studyguide-section subtopics-list .subtopic-subheading { - font-size: 16px; - margin-bottom: 16px; - } - - topic-viewer-page .redesigned-studyguide-section subtopics-list .subtopics-container { - gap: 16px; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - } - - topic-viewer-page .redesigned-stories-section { - margin-top: 16px; - padding: 18px 12px 34px; - } - - topic-viewer-page .redesigned-stories-list { - gap: 22px; - } - topic-viewer-page .topic-viewer-tabs-list { width: 90vw; } diff --git a/core/templates/pages/topic-viewer-page/topic-viewer-page.component.html b/core/templates/pages/topic-viewer-page/topic-viewer-page.component.html index e319c049a24fb..60aca50aa4268 100644 --- a/core/templates/pages/topic-viewer-page/topic-viewer-page.component.html +++ b/core/templates/pages/topic-viewer-page/topic-viewer-page.component.html @@ -1,57 +1,16 @@ -
-
- - -
-
-

- {{ 'I18N_TOPIC_VIEWER_STORIES_HEAD' | translate }} -

-
- - -
-
- -
- -

-

-
-
-
-
- -
-
- - -
-
-
+ [canonicalStorySectionData]="canonicalStorySectionData" + [activeView]="activeView" + [topicIsLoading]="topicIsLoading" + [subtopics]="subtopics"> +
diff --git a/core/templates/pages/topic-viewer-page/topic-viewer-page.component.spec.ts b/core/templates/pages/topic-viewer-page/topic-viewer-page.component.spec.ts index 14a16f43da978..6ac6cf6bd4f72 100644 --- a/core/templates/pages/topic-viewer-page/topic-viewer-page.component.spec.ts +++ b/core/templates/pages/topic-viewer-page/topic-viewer-page.component.spec.ts @@ -33,7 +33,6 @@ import {PageTitleService} from 'services/page-title.service'; import {MockTranslatePipe} from 'tests/unit-test-utils'; import {I18nLanguageCodeService} from 'services/i18n-language-code.service'; import {PlatformFeatureService} from 'services/platform-feature.service'; -import {StorySummary} from 'domain/story/story-summary.model'; class MockPlatformFeatureService { status = { @@ -184,6 +183,7 @@ describe('Topic viewer page', () => { }); afterEach(() => { + topicViewerPageComponent.ngOnDestroy(); httpTestingController.verify(); }); @@ -444,32 +444,6 @@ describe('Topic viewer page', () => { expect(alertsService.addWarning).not.toHaveBeenCalled(); })); - it('should track story data by story id', () => { - const storySummarySpy = jasmine.createSpyObj('StorySummary', [ - 'getId', - 'getTitle', - 'getDescription', - 'getNodeTitles', - 'getAllNodes', - 'getUrlFragment', - ]); - storySummarySpy.getId.and.returnValue('story_1'); - - expect( - topicViewerPageComponent.trackStoryDataById(0, { - storyId: 'story_1', - storyTitle: 'Story 1', - storyDescription: 'Description', - lessonCount: 3, - practiceCount: 2, - storySummary: storySummarySpy, - practiceSubtopicIds: [], - classroomUrlFragment: '', - topicUrlFragment: '', - }) - ).toBe('story_1'); - }); - it('should get static image url', () => { var imagePath = '/path/to/image.png'; var staticImageUrl = topicViewerPageComponent.getStaticImageUrl(imagePath); diff --git a/core/templates/pages/topic-viewer-page/topic-viewer-page.component.ts b/core/templates/pages/topic-viewer-page/topic-viewer-page.component.ts index 593b212eff48a..8fa03cccc82bc 100644 --- a/core/templates/pages/topic-viewer-page/topic-viewer-page.component.ts +++ b/core/templates/pages/topic-viewer-page/topic-viewer-page.component.ts @@ -16,7 +16,7 @@ * @fileoverview Component for the topic viewer. */ -import {Component, OnInit, OnDestroy, ViewEncapsulation} from '@angular/core'; +import {Component, OnInit, OnDestroy} from '@angular/core'; import {TranslateService} from '@ngx-translate/core'; import {Subscription} from 'rxjs'; @@ -35,25 +35,13 @@ import {PageTitleService} from 'services/page-title.service'; import {WindowRef} from 'services/contextual/window-ref.service'; import {I18nLanguageCodeService} from 'services/i18n-language-code.service'; import {PlatformFeatureService} from 'services/platform-feature.service'; +import {TopicViewerStorySectionData} from 'pages/topic-viewer-page/topic-viewer-content/topic-viewer-content.component'; import './topic-viewer-page.component.css'; -interface TopicViewerStorySectionData { - storyId: string; - storyTitle: string; - storyDescription: string; - storySummary: StorySummary; - practiceSubtopicIds: number[]; - classroomUrlFragment: string; - topicUrlFragment: string; - lessonCount: number; - practiceCount: number; -} - @Component({ selector: 'topic-viewer-page', templateUrl: './topic-viewer-page.component.html', styleUrls: ['./topic-viewer-page.component.css'], - encapsulation: ViewEncapsulation.None, }) export class TopicViewerPageComponent implements OnInit, OnDestroy { directiveSubscriptions = new Subscription(); @@ -163,13 +151,6 @@ export class TopicViewerPageComponent implements OnInit, OnDestroy { ); } - trackStoryDataById( - index: number, - storyData: TopicViewerStorySectionData - ): string { - return storyData.storyId; - } - private getCanonicalStorySectionData( readOnlyTopic: ReadOnlyTopic ): readonly TopicViewerStorySectionData[] { diff --git a/core/templates/pages/topic-viewer-page/topic-viewer-page.module.ts b/core/templates/pages/topic-viewer-page/topic-viewer-page.module.ts index 69aa63f2847a8..7044f8e9a9655 100644 --- a/core/templates/pages/topic-viewer-page/topic-viewer-page.module.ts +++ b/core/templates/pages/topic-viewer-page/topic-viewer-page.module.ts @@ -30,7 +30,6 @@ import {StoriesListComponent} from './deprecations/stories-list/topic-viewer-sto import {PracticeTabComponent} from './deprecations/practice-tab/practice-tab.component'; import {SubtopicsListComponent} from './subtopics-list/subtopics-list.component'; import {TopicViewerAccessGuard} from './topic-viewer-page-auth.guard'; -import {TopicHeaderComponent} from './topic-header/topic-header.component'; @NgModule({ imports: [ @@ -63,7 +62,6 @@ import {TopicHeaderComponent} from './topic-header/topic-header.component'; declarations: [ TopicViewerPageRootComponent, TopicViewerPageComponent, - TopicHeaderComponent, PracticeSessionConfirmationModal, ], entryComponents: [TopicViewerPageComponent, PracticeSessionConfirmationModal], diff --git a/core/templates/pages/topic-viewer-page/topic-viewer-player-common.module.ts b/core/templates/pages/topic-viewer-page/topic-viewer-player-common.module.ts index 68fa10f50eeba..4f4db4ce73aeb 100644 --- a/core/templates/pages/topic-viewer-page/topic-viewer-player-common.module.ts +++ b/core/templates/pages/topic-viewer-page/topic-viewer-player-common.module.ts @@ -30,11 +30,22 @@ import {AdventureEndTestCardComponent} from './topic-story-section/adventure-end import {AdventureCircleBadgeComponent} from './topic-story-section/adventure-circle-badge.component'; import {AdventureNavigationComponent} from './topic-story-section/adventure-navigation.component'; import {MasteryChallengeCardComponent} from './topic-story-section/mastery-challenge-card.component'; +import {ArcSkipConfirmationModalComponent} from './topic-story-section/arc-skip-confirmation-modal.component'; +import {AdventureMasteredModalComponent} from './topic-story-section/adventure-mastered-modal.component'; +import {TopicHeaderComponent} from './topic-header/topic-header.component'; +import {TopicViewerContentComponent} from './topic-viewer-content/topic-viewer-content.component'; +import {MatBottomSheetModule} from '@angular/material/bottom-sheet'; import {MatCardModule} from '@angular/material/card'; import {SharedComponentsModule} from 'components/shared-component.module'; @NgModule({ - imports: [CommonModule, FormsModule, MatCardModule, SharedComponentsModule], + imports: [ + CommonModule, + FormsModule, + MatBottomSheetModule, + MatCardModule, + SharedComponentsModule, + ], declarations: [ StoriesListComponent, SubtopicsListComponent, @@ -45,6 +56,10 @@ import {SharedComponentsModule} from 'components/shared-component.module'; AdventureCircleBadgeComponent, AdventureNavigationComponent, MasteryChallengeCardComponent, + ArcSkipConfirmationModalComponent, + AdventureMasteredModalComponent, + TopicHeaderComponent, + TopicViewerContentComponent, ], entryComponents: [ StoriesListComponent, @@ -56,6 +71,10 @@ import {SharedComponentsModule} from 'components/shared-component.module'; AdventureCircleBadgeComponent, AdventureNavigationComponent, MasteryChallengeCardComponent, + ArcSkipConfirmationModalComponent, + AdventureMasteredModalComponent, + TopicHeaderComponent, + TopicViewerContentComponent, ], exports: [ StoriesListComponent, @@ -67,6 +86,8 @@ import {SharedComponentsModule} from 'components/shared-component.module'; AdventureCircleBadgeComponent, AdventureNavigationComponent, MasteryChallengeCardComponent, + TopicHeaderComponent, + TopicViewerContentComponent, ], }) export class TopicPlayerViewerCommonModule {} diff --git a/core/templates/pages/voiceover-admin-page/modals/autogenerated-voiceover-run-info-modal.component.html b/core/templates/pages/voiceover-admin-page/modals/autogenerated-voiceover-run-info-modal.component.html index d4a5b7e00bfcb..73ff1182b77e2 100644 --- a/core/templates/pages/voiceover-admin-page/modals/autogenerated-voiceover-run-info-modal.component.html +++ b/core/templates/pages/voiceover-admin-page/modals/autogenerated-voiceover-run-info-modal.component.html @@ -13,10 +13,10 @@

Voiceover Regeneration Information

- Function ID: + Event:
- {{getFunctionIdText(cloudTaskRun.functionId)}} + {{cloudTaskRun.getEventName()}}
@@ -47,14 +47,11 @@

Voiceover Regeneration Information

-
+
Error messages:
-
- No error messages. -
-
+
@@ -95,8 +92,8 @@

Voiceover Regeneration Information

.error-messages-container { background-color: rgba(0, 0, 0, 0.06); font-family: monospace; - height: 250px; - overflow: scroll; + max-height: 250px; + overflow: auto; padding: 2%; white-space: pre-wrap; } diff --git a/core/templates/pages/voiceover-admin-page/voiceover-admin-page.component.html b/core/templates/pages/voiceover-admin-page/voiceover-admin-page.component.html index fb9e314a52d60..e82d7e920e85a 100644 --- a/core/templates/pages/voiceover-admin-page/voiceover-admin-page.component.html +++ b/core/templates/pages/voiceover-admin-page/voiceover-admin-page.component.html @@ -244,27 +244,37 @@

Automatic voiceover updates

Event - {{getFunctionIdText(element.functionId)}} + {{element.getEventName()}} + + + + Language Accents + + +
+ {{ accent.trim() }} +
+
Number of retries - {{element.currentRetryAttempt}} + {{element.currentRetryAttempt}} Started on - {{element.createdOn | date:'medium'}} + {{element.createdOn | date:'medium'}} Last updated - {{element.lastUpdated | date:'medium'}} + {{element.lastUpdated | date:'medium'}} Details - + @@ -282,6 +292,15 @@

Automatic voiceover updates