diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0af486efc497..fcc4f41522d7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -297,6 +297,7 @@ /core/templates/components/common-layout-directives/common-elements/sharing-links.component.html @oppia/lace-frontend-reviewers /core/templates/components/common-layout-directives/common-elements/sharing-links.component*.ts @oppia/lace-frontend-reviewers /core/templates/components/common-layout-directives/common-elements/common-elements.module.ts @oppia/lace-frontend-reviewers +/core/templates/components/custom-snackbar/ @oppia/lace-frontend-reviewers /core/templates/components/profile-link-directives/ @oppia/lace-frontend-reviewers /core/templates/components/summary-tile/ @oppia/lace-frontend-reviewers /core/templates/directives/angular-html-bind.directive*.ts @oppia/lace-frontend-reviewers diff --git a/core/templates/components/custom-snackbar/undo-snackbar.component.html b/core/templates/components/custom-snackbar/undo-snackbar.component.html new file mode 100644 index 000000000000..52065d711066 --- /dev/null +++ b/core/templates/components/custom-snackbar/undo-snackbar.component.html @@ -0,0 +1,35 @@ +
+ diff --git a/core/templates/components/custom-snackbar/undo-snackbar.component.spec.ts b/core/templates/components/custom-snackbar/undo-snackbar.component.spec.ts new file mode 100644 index 000000000000..5f249d129d35 --- /dev/null +++ b/core/templates/components/custom-snackbar/undo-snackbar.component.spec.ts @@ -0,0 +1,59 @@ +// Copyright 2024 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 Custom undo snackbar. + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatSnackBarRef} from '@angular/material/snack-bar'; +import {UndoSnackbarComponent} from './undo-snackbar.component'; + +describe('CustomSnackbarComponent', () => { + let component: UndoSnackbarComponent; + let fixture: ComponentFixturecontent
', + translation_html: 'Tradução', + state_name: 'StateName', + cmd: 'edit_state_property', + data_format: 'html', + language_code: 'language_code', + }, + exploration_content_html: '
content
', + }; + + const suggestion2 = { + author_name: 'author_name', + language_code: 'language_code', + last_updated_msecs: 1559074000000, + status: 'status', + suggestion_id: 'suggestion_2', + target_id: '2', + target_type: 'target_type', + suggestion_type: 'translate_content', + change_cmd: { + content_id: 'hint_1', + content_html: '
content
', + translation_html: 'Tradução', + state_name: 'StateName', + cmd: 'edit_state_property', + data_format: 'html', + language_code: 'language_code', + }, + exploration_content_html: 'content CHANGED
', + }; + + const contribution1 = { + suggestion: suggestion1, + details: { + topic_name: 'topic_1', + story_title: 'story_1', + chapter_title: 'chapter_1', + }, + }; + const contribution2 = { + suggestion: suggestion2, + details: { + topic_name: 'topic_2', + story_title: 'story_2', + chapter_title: 'chapter_2', + }, + }; + + const suggestionIdToContribution = { + suggestion_1: contribution1, + suggestion_2: contribution2, + }; + + const editedContent = { + html: 'In Hindi
', + }; + + const userInfo = new UserInfo( + ['USER_ROLE'], + true, + false, + false, + false, + true, + 'en', + 'username1', + 'tester@example.com', + true + ); + + beforeEach(() => { + component.initialSuggestionId = 'suggestion_1'; + component.subheading = subheading; + component.reviewable = reviewable; + component.suggestionIdToContribution = angular.copy( + suggestionIdToContribution + ); + component.editedContent = editedContent; + mockPlatformFeatureService.status.CdAllowUndoingTranslationReview.isEnabled = + false; + }); + + it('should call user service at initialization.', function () { + const userInfoSpy = spyOn( + userService, + 'getUserInfoAsync' + ).and.returnValue(Promise.resolve(userInfo)); + + const contributionRightsDataSpy = spyOn( + userService, + 'getUserContributionRightsDataAsync' + ).and.returnValue( + Promise.resolve({ + can_review_translation_for_language_codes: ['ar'], + can_review_voiceover_for_language_codes: [], + can_review_questions: false, + can_suggest_questions: false, + }) + ); + component.ngOnInit(); + expect(userInfoSpy).toHaveBeenCalled(); + expect(contributionRightsDataSpy).toHaveBeenCalled(); + }); + + it('should throw error if username is invalid', fakeAsync(() => { + const defaultUserInfo = new UserInfo( + ['GUEST'], + false, + false, + false, + false, + false, + null, + null, + null, + false + ); + spyOn(userService, 'getUserInfoAsync').and.returnValue( + Promise.resolve(defaultUserInfo) + ); + + expect(() => { + component.ngOnInit(); + tick(); + }).toThrowError(); + flush(); + })); + + it('should initialize $scope properties after controller is initialized', function () { + component.ngOnInit(); + expect(component.subheading).toBe(subheading); + expect(component.reviewable).toBe(reviewable); + expect(component.activeSuggestionId).toBe('suggestion_1'); + expect(component.activeSuggestion).toEqual(suggestion1); + expect(component.reviewMessage).toBe(''); + }); + + it( + 'should register Contributor Dashboard view suggestion for review ' + + 'event after controller is initialized', + function () { + component.ngOnInit(); + expect( + siteAnalyticsService.registerContributorDashboardViewSuggestionForReview + ).toHaveBeenCalledWith('Translation'); + } + ); + + it('should notify user on failed suggestion update', function () { + component.ngOnInit(); + const error = new Error('Error'); + expect(component.errorFound).toBeFalse(); + expect(component.errorMessage).toBe(''); + + component.showTranslationSuggestionUpdateError(error); + + expect(component.errorFound).toBeTrue(); + expect(component.errorMessage).toBe('Invalid Suggestion: Error'); + }); + + it( + 'should accept suggestion in suggestion modal service when clicking' + + ' on accept and review next suggestion button', + function () { + component.ngOnInit(); + expect(component.activeSuggestionId).toBe('suggestion_1'); + expect(component.activeSuggestion).toEqual(suggestion1); + expect(component.reviewable).toBe(reviewable); + expect(component.reviewMessage).toBe(''); + // Suggestion 1's exploration_content_html matches its content_html. + expect(component.hasExplorationContentChanged()).toBe(false); + + spyOn( + siteAnalyticsService, + 'registerContributorDashboardAcceptSuggestion' + ); + spyOn( + contributionAndReviewService, + 'reviewExplorationSuggestion' + ).and.callFake( + ( + targetId, + suggestionId, + action, + reviewMessage, + commitMessage, + successCallback, + errorCallback + ) => { + return Promise.resolve(successCallback(suggestionId)); + } + ); + spyOn(activeModal, 'close'); + spyOn(alertsService, 'addSuccessMessage'); + + component.reviewMessage = 'Review message example'; + component.translationUpdated = true; + component.acceptAndReviewNext(); + + expect(component.activeSuggestionId).toBe('suggestion_2'); + expect(component.activeSuggestion).toEqual(suggestion2); + expect(component.reviewable).toBe(reviewable); + expect(component.reviewMessage).toBe(''); + // Suggestion 2's exploration_content_html does not match its + // content_html. + expect(component.hasExplorationContentChanged()).toBe(true); + expect( + siteAnalyticsService.registerContributorDashboardAcceptSuggestion + ).toHaveBeenCalledWith('Translation'); + expect( + contributionAndReviewService.reviewExplorationSuggestion + ).toHaveBeenCalledWith( + '1', + 'suggestion_1', + 'accept', + 'Review message example: ' + + '(Note: This suggestion was submitted with reviewer edits.)', + 'hint section of "StateName" card', + jasmine.any(Function), + jasmine.any(Function) + ); + expect(alertsService.addSuccessMessage).toHaveBeenCalled(); + + component.reviewMessage = 'Review message example 2'; + component.translationUpdated = false; + component.acceptAndReviewNext(); + + expect( + siteAnalyticsService.registerContributorDashboardAcceptSuggestion + ).toHaveBeenCalledWith('Translation'); + expect( + contributionAndReviewService.reviewExplorationSuggestion + ).toHaveBeenCalledWith( + '2', + 'suggestion_2', + 'accept', + 'Review message example 2', + 'hint section of "StateName" card', + jasmine.any(Function), + jasmine.any(Function) + ); + expect(alertsService.addSuccessMessage).toHaveBeenCalled(); + expect(activeModal.close).toHaveBeenCalledWith([ + 'suggestion_1', + 'suggestion_2', + ]); + } + ); + + it( + 'should set suggestion review message to auto-generated note when ' + + 'suggestion is accepted with edits and no user-supplied review message', + function () { + component.ngOnInit(); + expect(component.activeSuggestionId).toBe('suggestion_1'); + expect(component.activeSuggestion).toEqual(suggestion1); + expect(component.reviewable).toBe(reviewable); + expect(component.reviewMessage).toBe(''); + + spyOn( + siteAnalyticsService, + 'registerContributorDashboardAcceptSuggestion' + ); + spyOn( + contributionAndReviewService, + 'reviewExplorationSuggestion' + ).and.callFake( + ( + targetId, + suggestionId, + action, + reviewMessage, + commitMessage, + successCallback, + errorCallback + ) => { + return Promise.resolve(successCallback(suggestionId)); + } + ); + spyOn(alertsService, 'addSuccessMessage'); + + component.translationUpdated = true; + component.acceptAndReviewNext(); + + expect( + siteAnalyticsService.registerContributorDashboardAcceptSuggestion + ).toHaveBeenCalledWith('Translation'); + expect( + contributionAndReviewService.reviewExplorationSuggestion + ).toHaveBeenCalledWith( + '1', + 'suggestion_1', + 'accept', + '(Note: This suggestion was submitted with reviewer edits.)', + 'hint section of "StateName" card', + jasmine.any(Function), + jasmine.any(Function) + ); + expect(alertsService.addSuccessMessage).toHaveBeenCalled(); + } + ); + + it( + 'should reject suggestion in suggestion modal service when clicking ' + + 'on reject and review next suggestion button', + function () { + component.ngOnInit(); + expect(component.activeSuggestionId).toBe('suggestion_1'); + expect(component.activeSuggestion).toEqual(suggestion1); + expect(component.reviewable).toBe(reviewable); + expect(component.reviewMessage).toBe(''); + + spyOn( + contributionAndReviewService, + 'reviewExplorationSuggestion' + ).and.callFake( + ( + targetId, + suggestionId, + action, + reviewMessage, + commitMessage, + successCallback, + errorCallback + ) => { + return Promise.resolve(successCallback(suggestionId)); + } + ); + spyOn( + siteAnalyticsService, + 'registerContributorDashboardRejectSuggestion' + ); + spyOn(activeModal, 'close'); + spyOn(alertsService, 'addSuccessMessage'); + + component.reviewMessage = 'Review message example'; + component.translationUpdated = true; + component.rejectAndReviewNext(component.reviewMessage); + + expect(component.activeSuggestionId).toBe('suggestion_2'); + expect(component.activeSuggestion).toEqual(suggestion2); + expect(component.reviewable).toBe(reviewable); + expect(component.reviewMessage).toBe(''); + expect( + siteAnalyticsService.registerContributorDashboardRejectSuggestion + ).toHaveBeenCalledWith('Translation'); + expect( + contributionAndReviewService.reviewExplorationSuggestion + ).toHaveBeenCalledWith( + '1', + 'suggestion_1', + 'reject', + 'Review message example', + null, + jasmine.any(Function), + jasmine.any(Function) + ); + expect(alertsService.addSuccessMessage).toHaveBeenCalled(); + + component.reviewMessage = 'Review message example 2'; + component.translationUpdated = false; + component.rejectAndReviewNext(component.reviewMessage); + + expect( + siteAnalyticsService.registerContributorDashboardRejectSuggestion + ).toHaveBeenCalledWith('Translation'); + expect(alertsService.addSuccessMessage).toHaveBeenCalled(); + expect(activeModal.close).toHaveBeenCalledWith([ + 'suggestion_1', + 'suggestion_2', + ]); + } + ); + + it( + 'should allow the reviewer to fix the suggestion if the backend pre' + + ' accept/reject validation failed', + function () { + const responseMessage = 'Pre accept validation failed.'; + + component.ngOnInit(); + expect(component.activeSuggestionId).toBe('suggestion_1'); + expect(component.activeSuggestion).toEqual(suggestion1); + expect(component.reviewable).toBe(reviewable); + expect(component.reviewMessage).toBe(''); + spyOn( + siteAnalyticsService, + 'registerContributorDashboardAcceptSuggestion' + ); + spyOn( + siteAnalyticsService, + 'registerContributorDashboardRejectSuggestion' + ); + spyOn( + contributionAndReviewService, + 'reviewExplorationSuggestion' + ).and.callFake( + ( + targetId, + suggestionId, + action, + reviewMessage, + commitMessage, + successCallback, + errorCallback + ) => { + return Promise.reject(errorCallback(responseMessage)); + } + ); + spyOn(alertsService, 'addWarning'); + + component.reviewMessage = 'Review message example'; + component.acceptAndReviewNext(); + + expect(component.activeSuggestionId).toBe('suggestion_1'); + expect(component.activeSuggestion).toEqual(suggestion1); + expect(component.reviewable).toBe(reviewable); + expect(component.reviewMessage).toBe('Review message example'); + expect( + siteAnalyticsService.registerContributorDashboardAcceptSuggestion + ).toHaveBeenCalledWith('Translation'); + expect( + contributionAndReviewService.reviewExplorationSuggestion + ).toHaveBeenCalledWith( + '1', + 'suggestion_1', + 'accept', + 'Review message example', + 'hint section of "StateName" card', + jasmine.any(Function), + jasmine.any(Function) + ); + expect(alertsService.addWarning).toHaveBeenCalledWith( + jasmine.stringContaining(responseMessage) + ); + + component.reviewMessage = 'Edited review message example'; + component.rejectAndReviewNext(component.reviewMessage); + + expect(component.activeSuggestionId).toBe('suggestion_1'); + expect(component.activeSuggestion).toEqual(suggestion1); + expect(component.reviewable).toBe(reviewable); + expect(component.reviewMessage).toBe('Edited review message example'); + expect( + siteAnalyticsService.registerContributorDashboardRejectSuggestion + ).toHaveBeenCalledWith('Translation'); + expect( + contributionAndReviewService.reviewExplorationSuggestion + ).toHaveBeenCalledWith( + '1', + 'suggestion_1', + 'reject', + 'Edited review message example', + null, + jasmine.any(Function), + jasmine.any(Function) + ); + expect(alertsService.addWarning).toHaveBeenCalledWith( + jasmine.stringContaining(responseMessage) + ); + } + ); + + it( + 'should cancel suggestion in suggestion modal service when clicking ' + + 'on cancel suggestion button', + function () { + spyOn(activeModal, 'close'); + component.cancel(); + expect(activeModal.close).toHaveBeenCalledWith([]); + } + ); + + it('should open the translation editor when the edit button is clicked', function () { + component.editSuggestion(); + expect(component.startedEditing).toBe(true); + }); + + it('should close the translation editor when the cancel button is clicked', function () { + component.cancelEdit(); + expect(component.startedEditing).toBe(false); + }); + + it('should expand the content area', () => { + spyOn(component, 'toggleExpansionState').and.callThrough(); + // The content area is contracted by default. + expect(component.isContentExpanded).toBeFalse(); + // The content area should expand when the users clicks + // on the 'View More' button. + component.toggleExpansionState(0); + + expect(component.isContentExpanded).toBeTrue(); + }); + + it('should contract the content area', () => { + spyOn(component, 'toggleExpansionState').and.callThrough(); + component.isContentExpanded = true; + // The content area should contract when the users clicks + // on the 'View Less' button. + component.toggleExpansionState(0); + + expect(component.isContentExpanded).toBeFalse(); + }); + + it('should expand the translation area', () => { + spyOn(component, 'toggleExpansionState').and.callThrough(); + // The translation area is contracted by default. + expect(component.isTranslationExpanded).toBeFalse(); + // The translation area should expand when the users clicks + // on the 'View More' button. + component.toggleExpansionState(1); + + expect(component.isTranslationExpanded).toBeTrue(); + }); + + it('should contract the translation area', () => { + spyOn(component, 'toggleExpansionState').and.callThrough(); + component.isTranslationExpanded = true; + // The translation area should contract when the users clicks + // on the 'View Less' button. + component.toggleExpansionState(1); + + expect(component.isTranslationExpanded).toBeFalse(); + }); + + it('should update translation when the update button is clicked', function () { + component.ngOnInit(); + spyOn( + contributionAndReviewService, + 'updateTranslationSuggestionAsync' + ).and.callFake( + (suggestionId, translationHtml, successCallback, errorCallback) => { + return Promise.resolve(successCallback()); + } + ); + + component.updateSuggestion(); + + expect( + contributionAndReviewService.updateTranslationSuggestionAsync + ).toHaveBeenCalledWith( + 'suggestion_1', + component.editedContent.html, + jasmine.any(Function), + jasmine.any(Function) + ); + }); + + describe('isHtmlContentEqual', function () { + it('should return true regardless of differences', function () { + expect( + component.isHtmlContentEqual( + 'content
', + '
content
' + ) + ).toBe(true); + }); + + it('should return true regardless of new line differences', function () { + expect( + component.isHtmlContentEqual( + '
content
\r\n\ncontent2
', + 'content
content2
' + ) + ).toBe(true); + }); + + it('should return false if html content differ', function () { + expect( + component.isHtmlContentEqual( + 'content
', + 'content CHANGED
' + ) + ).toBe(false); + }); + + it('should return false if array contents differ', function () { + expect( + component.isHtmlContentEqual( + ['content1
', 'content2
'], + ['content1
', 'content2 CHANGED
'] + ) + ).toBe(false); + }); + + it('should return true if array contents are equal', function () { + expect( + component.isHtmlContentEqual( + ['content1
', 'content2
'], + ['content1
', 'content2
'] + ) + ).toBe(true); + }); + + it('should return false if type is different', function () { + expect( + component.isHtmlContentEqual( + ['content1
', 'content2
'], + 'content2
' + ) + ).toBe(false); + }); + }); + }); + + describe('when reviewing suggestions with deleted opportunites when flag CdAllowUndoingTranslationReview is disabled', function () { + const reviewable = true; + const subheading = 'topic_1 / story_1 / chapter_1'; + + const suggestion1 = { + suggestion_id: 'suggestion_1', + target_id: '1', + suggestion_type: 'translate_content', + change_cmd: { + content_id: 'hint_1', + content_html: ['Translation1', 'Translation2'], + translation_html: 'Tradução', + state_name: 'StateName', + cmd: 'edit_state_property', + data_format: 'html', + language_code: 'language_code', + }, + exploration_content_html: ['Translation1', 'Translation2 CHANGED'], + status: 'rejected', + author_name: 'author_name', + language_code: 'language_code', + last_updated_msecs: 1559074000000, + target_type: 'target_type', + }; + const suggestion2 = { + suggestion_id: 'suggestion_2', + target_id: '2', + suggestion_type: 'translate_content', + change_cmd: { + content_id: 'hint_1', + content_html: 'Translation', + translation_html: 'Tradução', + state_name: 'StateName', + cmd: 'edit_state_property', + data_format: 'html', + language_code: 'language_code', + }, + exploration_content_html: 'Translation', + status: 'rejected', + author_name: 'author_name', + language_code: 'language_code', + last_updated_msecs: 1559074000000, + target_type: 'target_type', + }; + + const contribution1 = { + suggestion: suggestion1, + details: { + topic_name: 'topic_1', + story_title: 'story_1', + chapter_title: 'chapter_1', + }, + }; + + const deletedContribution = { + suggestion: suggestion2, + details: null, + }; + + const suggestionIdToContribution = { + suggestion_1: contribution1, + suggestion_deleted: deletedContribution, + }; + + beforeEach(() => { + component.initialSuggestionId = 'suggestion_1'; + component.subheading = subheading; + component.reviewable = reviewable; + component.suggestionIdToContribution = angular.copy( + suggestionIdToContribution + ); + mockPlatformFeatureService.status.CdAllowUndoingTranslationReview.isEnabled = + false; + component.ngOnInit(); + }); + + it( + 'should reject suggestion in suggestion modal service when clicking ' + + 'on reject and review next suggestion button', + function () { + expect(component.activeSuggestionId).toBe('suggestion_1'); + expect(component.activeSuggestion).toEqual(suggestion1); + expect(component.reviewable).toBe(reviewable); + expect(component.reviewMessage).toBe(''); + + spyOn( + contributionAndReviewService, + 'reviewExplorationSuggestion' + ).and.callFake( + ( + targetId, + suggestionId, + action, + reviewMessage, + commitMessage, + successCallback, + errorCallback + ) => { + return Promise.resolve(successCallback(suggestionId)); + } + ); + spyOn( + siteAnalyticsService, + 'registerContributorDashboardRejectSuggestion' + ); + spyOn(activeModal, 'close'); + spyOn(alertsService, 'addSuccessMessage'); + + component.reviewMessage = 'Review message example'; + component.rejectAndReviewNext(component.reviewMessage); + + expect( + siteAnalyticsService.registerContributorDashboardRejectSuggestion + ).toHaveBeenCalledWith('Translation'); + expect( + contributionAndReviewService.reviewExplorationSuggestion + ).toHaveBeenCalledWith( + '1', + 'suggestion_1', + 'reject', + 'Review message example', + null, + jasmine.any(Function), + jasmine.any(Function) + ); + expect(alertsService.addSuccessMessage).toHaveBeenCalledWith( + 'Suggestion rejected.' + ); + expect(activeModal.close).toHaveBeenCalledWith(['suggestion_1']); + } + ); + }); + + describe('when viewing suggestion', function () { + const reviewable = false; + const subheading = 'topic_1 / story_1 / chapter_1'; + + const suggestion1 = { + suggestion_id: 'suggestion_1', + target_id: '1', + suggestion_type: 'translate_content', + change_cmd: { + content_id: 'hint_1', + content_html: ['Translation1', 'Translation2'], + translation_html: 'Tradução', + state_name: 'StateName', + cmd: 'edit_state_property', + data_format: 'html', + language_code: 'language_code', + }, + exploration_content_html: ['Translation1', 'Translation2 CHANGED'], + status: 'rejected', + author_name: 'author_name', + language_code: 'language_code', + last_updated_msecs: 1559074000000, + target_type: 'target_type', + }; + const suggestion2 = { + suggestion_id: 'suggestion_2', + target_id: '2', + suggestion_type: 'translate_content', + change_cmd: { + content_id: 'hint_1', + content_html: 'Translation', + translation_html: 'Tradução', + state_name: 'StateName', + cmd: 'edit_state_property', + data_format: 'html', + language_code: 'language_code', + }, + exploration_content_html: 'Translation', + author_name: 'author_name', + language_code: 'language_code', + last_updated_msecs: 1559074000000, + status: 'status', + target_type: 'target_type', + }; + const obsoleteSuggestion = { + suggestion_id: 'suggestion_3', + target_id: '3', + suggestion_type: 'translate_content', + change_cmd: { + content_id: 'hint_1', + content_html: 'Translation', + translation_html: 'Tradução', + state_name: 'StateName', + cmd: 'edit_state_property', + data_format: 'html', + language_code: 'language_code', + }, + // This suggestion is obsolete. + exploration_content_html: null, + author_name: 'author_name', + language_code: 'language_code', + last_updated_msecs: 1559074000000, + status: 'status', + target_type: 'target_type', + }; + + const contribution1 = { + suggestion: suggestion1, + details: { + topic_name: 'topic_1', + story_title: 'story_1', + chapter_title: 'chapter_1', + }, + }; + const contribution2 = { + suggestion: suggestion2, + details: { + topic_name: 'topic_2', + story_title: 'story_2', + chapter_title: 'chapter_2', + }, + }; + const contribution3 = { + suggestion: obsoleteSuggestion, + details: { + topic_name: 'topic_3', + story_title: 'story_3', + chapter_title: 'chapter_3', + }, + }; + + const suggestionIdToContribution = { + suggestion_1: contribution1, + suggestion_2: contribution2, + suggestion_3: contribution3, + }; + + beforeEach(() => { + component.initialSuggestionId = 'suggestion_1'; + component.subheading = subheading; + component.reviewable = reviewable; + component.suggestionIdToContribution = angular.copy( + suggestionIdToContribution + ); + }); + + it('should initialize $scope properties after controller is initialized', fakeAsync(function () { + const messages = [ + { + author_username: '', + created_on_msecs: 0, + entity_type: '', + entity_id: '', + message_id: 0, + text: '', + updated_status: '', + updated_subject: '', + }, + { + author_username: 'Reviewer', + created_on_msecs: 0, + entity_type: '', + entity_id: '', + message_id: 0, + text: 'Review Message', + updated_status: 'fixed', + updated_subject: null, + }, + ]; + + const fetchMessagesAsyncSpy = spyOn( + threadDataBackendApiService, + 'fetchMessagesAsync' ).and.returnValue(Promise.resolve({messages: messages})); component.ngOnInit(); @@ -1082,7 +2111,7 @@ describe('Translation Suggestion Review Modal Component', function () { tick(); expect(component.activeSuggestionId).toBe('suggestion_3'); - expect(component.activeSuggestion).toEqual(suggestion3Obsolete); + expect(component.activeSuggestion).toEqual(obsoleteSuggestion); expect(component.reviewable).toBe(reviewable); expect(component.subheading).toBe('topic_3 / story_3 / chapter_3'); // Suggestion 3's exploration_content_html does not match its @@ -1095,139 +2124,215 @@ describe('Translation Suggestion Review Modal Component', function () { })); }); - describe( - 'when reviewing suggestions' + ' with deleted opportunites', - function () { - const reviewable = true; - const subheading = 'topic_1 / story_1 / chapter_1'; + describe('when viewing suggestion', function () { + const reviewable = false; + const subheading = 'topic_1 / story_1 / chapter_1'; - const suggestion1 = { - suggestion_id: 'suggestion_1', - target_id: '1', - suggestion_type: 'translate_content', - change_cmd: { - content_id: 'hint_1', - content_html: ['Translation1', 'Translation2'], - translation_html: 'Tradução', - state_name: 'StateName', - cmd: 'edit_state_property', - data_format: 'html', - language_code: 'language_code', - }, - exploration_content_html: ['Translation1', 'Translation2 CHANGED'], - status: 'rejected', - author_name: 'author_name', + const suggestion1 = { + suggestion_id: 'suggestion_1', + target_id: '1', + suggestion_type: 'translate_content', + change_cmd: { + content_id: 'hint_1', + content_html: ['Translation1', 'Translation2'], + translation_html: 'Tradução', + state_name: 'StateName', + cmd: 'edit_state_property', + data_format: 'html', language_code: 'language_code', - last_updated_msecs: 1559074000000, - target_type: 'target_type', - }; - const suggestion2 = { - suggestion_id: 'suggestion_2', - target_id: '2', - suggestion_type: 'translate_content', - change_cmd: { - content_id: 'hint_1', - content_html: 'Translation', - translation_html: 'Tradução', - state_name: 'StateName', - cmd: 'edit_state_property', - data_format: 'html', - language_code: 'language_code', - }, - exploration_content_html: 'Translation', - status: 'rejected', - author_name: 'author_name', + }, + exploration_content_html: ['Translation1', 'Translation2 CHANGED'], + status: 'rejected', + author_name: 'author_name', + language_code: 'language_code', + last_updated_msecs: 1559074000000, + target_type: 'target_type', + }; + const suggestion2 = { + suggestion_id: 'suggestion_2', + target_id: '2', + suggestion_type: 'translate_content', + change_cmd: { + content_id: 'hint_1', + content_html: 'Translation', + translation_html: 'Tradução', + state_name: 'StateName', + cmd: 'edit_state_property', + data_format: 'html', language_code: 'language_code', - last_updated_msecs: 1559074000000, - target_type: 'target_type', - }; + }, + exploration_content_html: 'Translation', + author_name: 'author_name', + language_code: 'language_code', + last_updated_msecs: 1559074000000, + status: 'status', + target_type: 'target_type', + }; + const obsoleteSuggestion = { + suggestion_id: 'suggestion_3', + target_id: '3', + suggestion_type: 'translate_content', + change_cmd: { + content_id: 'hint_1', + content_html: 'Translation', + translation_html: 'Tradução', + state_name: 'StateName', + cmd: 'edit_state_property', + data_format: 'html', + language_code: 'language_code', + }, + // This suggestion is obsolete. + exploration_content_html: null, + author_name: 'author_name', + language_code: 'language_code', + last_updated_msecs: 1559074000000, + status: 'status', + target_type: 'target_type', + }; + + const contribution1 = { + suggestion: suggestion1, + details: { + topic_name: 'topic_1', + story_title: 'story_1', + chapter_title: 'chapter_1', + }, + }; + const contribution2 = { + suggestion: suggestion2, + details: { + topic_name: 'topic_2', + story_title: 'story_2', + chapter_title: 'chapter_2', + }, + }; + const contribution3 = { + suggestion: obsoleteSuggestion, + details: { + topic_name: 'topic_3', + story_title: 'story_3', + chapter_title: 'chapter_3', + }, + }; + + const suggestionIdToContribution = { + suggestion_1: contribution1, + suggestion_2: contribution2, + suggestion_3: contribution3, + }; + + beforeEach(() => { + component.initialSuggestionId = 'suggestion_1'; + component.subheading = subheading; + component.reviewable = reviewable; + component.suggestionIdToContribution = angular.copy( + suggestionIdToContribution + ); + }); - const contribution1 = { - suggestion: suggestion1, - details: { - topic_name: 'topic_1', - story_title: 'story_1', - chapter_title: 'chapter_1', + it('should initialize $scope properties after controller is initialized', fakeAsync(function () { + const messages = [ + { + author_username: '', + created_on_msecs: 0, + entity_type: '', + entity_id: '', + message_id: 0, + text: '', + updated_status: '', + updated_subject: '', }, - }; + { + author_username: 'Reviewer', + created_on_msecs: 0, + entity_type: '', + entity_id: '', + message_id: 0, + text: 'Review Message', + updated_status: 'fixed', + updated_subject: null, + }, + ]; - const deletedContribution = { - suggestion: suggestion2, - details: null, - }; + const fetchMessagesAsyncSpy = spyOn( + threadDataBackendApiService, + 'fetchMessagesAsync' + ).and.returnValue(Promise.resolve({messages: messages})); - const suggestionIdToContribution = { - suggestion_1: contribution1, - suggestion_deleted: deletedContribution, - }; + component.ngOnInit(); + component.refreshActiveContributionState(); + tick(); - beforeEach(() => { - component.initialSuggestionId = 'suggestion_1'; - component.subheading = subheading; - component.reviewable = reviewable; - component.suggestionIdToContribution = angular.copy( - suggestionIdToContribution - ); - component.ngOnInit(); - }); + expect(component.activeSuggestionId).toBe('suggestion_1'); + expect(component.activeSuggestion).toEqual(suggestion1); + expect(component.reviewable).toBe(reviewable); + expect(component.subheading).toBe('topic_1 / story_1 / chapter_1'); + // Suggestion 1's exploration_content_html does not match its + // content_html. + expect(component.hasExplorationContentChanged()).toBe(true); + expect(fetchMessagesAsyncSpy).toHaveBeenCalledWith('suggestion_1'); + expect(component.reviewMessage).toBe('Review Message'); + expect(component.reviewer).toBe('Reviewer'); + })); - it( - 'should reject suggestion in suggestion modal service when clicking ' + - 'on reject and review next suggestion button', - function () { - expect(component.activeSuggestionId).toBe('suggestion_1'); - expect(component.activeSuggestion).toEqual(suggestion1); - expect(component.reviewable).toBe(reviewable); - expect(component.reviewMessage).toBe(''); - - spyOn( - contributionAndReviewService, - 'reviewExplorationSuggestion' - ).and.callFake( - ( - targetId, - suggestionId, - action, - reviewMessage, - commitMessage, - successCallback, - errorCallback - ) => { - return Promise.resolve(successCallback(suggestionId)); - } - ); - spyOn( - siteAnalyticsService, - 'registerContributorDashboardRejectSuggestion' - ); - spyOn(activeModal, 'close'); - spyOn(alertsService, 'addSuccessMessage'); - - component.reviewMessage = 'Review message example'; - component.rejectAndReviewNext(component.reviewMessage); - - expect( - siteAnalyticsService.registerContributorDashboardRejectSuggestion - ).toHaveBeenCalledWith('Translation'); - expect( - contributionAndReviewService.reviewExplorationSuggestion - ).toHaveBeenCalledWith( - '1', - 'suggestion_1', - 'reject', - 'Review message example', - null, - jasmine.any(Function), - jasmine.any(Function) - ); - expect(alertsService.addSuccessMessage).toHaveBeenCalledWith( - 'Suggestion rejected.' - ); - expect(activeModal.close).toHaveBeenCalledWith(['suggestion_1']); - } + it('should correctly determine whether the panel data is overflowing', fakeAsync(() => { + // Pre-check. + // The default values for the overflow states are false. + expect(component.isContentOverflowing).toBeFalse(); + expect(component.isTranslationOverflowing).toBeFalse(); + // Setup. + component.contentPanel.elementRef.nativeElement.offsetHeight = 100; + component.translationPanel.elementRef.nativeElement.offsetHeight = 200; + component.contentContainer.nativeElement.offsetHeight = 150; + component.translationContainer.nativeElement.offsetHeight = 150; + // Action. + component.computePanelOverflowState(); + tick(0); + // Expectations. + expect(component.isContentOverflowing).toBeFalse(); + expect(component.isTranslationOverflowing).toBeTrue(); + // Change panel height to simulate changing of the modal data. + component.contentPanel.elementRef.nativeElement.offsetHeight = 300; + // Action. + component.computePanelOverflowState(); + tick(0); + // Expectations. + expect(component.isContentOverflowing).toBeTrue(); + expect(component.isTranslationOverflowing).toBeTrue(); + })); + + it('should determine panel height after view initialization', () => { + spyOn(component, 'computePanelOverflowState').and.callFake(() => {}); + + component.ngAfterViewInit(); + + expect(component.computePanelOverflowState).toHaveBeenCalled(); + }); + + it('should set Obsolete review message for obsolete suggestions', fakeAsync(function () { + const fetchMessagesAsyncSpy = spyOn( + threadDataBackendApiService, + 'fetchMessagesAsync' + ).and.returnValue(Promise.resolve({messages: []})); + component.initialSuggestionId = 'suggestion_3'; + + component.ngOnInit(); + component.refreshActiveContributionState(); + tick(); + + expect(component.activeSuggestionId).toBe('suggestion_3'); + expect(component.activeSuggestion).toEqual(obsoleteSuggestion); + expect(component.reviewable).toBe(reviewable); + expect(component.subheading).toBe('topic_3 / story_3 / chapter_3'); + // Suggestion 3's exploration_content_html does not match its + // content_html. + expect(component.hasExplorationContentChanged()).toBe(true); + expect(fetchMessagesAsyncSpy).toHaveBeenCalledWith('suggestion_3'); + expect(component.reviewMessage).toBe( + AppConstants.OBSOLETE_TRANSLATION_SUGGESTION_REVIEW_MSG ); - } - ); + })); + }); describe('when navigating through suggestions', function () { const reviewable = false; diff --git a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts index 51d5d64fd65d..60f000a7e3e3 100644 --- a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts +++ b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts @@ -42,6 +42,9 @@ import {UserContributionRightsDataBackendDict} from 'services/user-backend-api.s // suppress this error because rte-output-display is not strictly typed yet. // @ts-ignore import {RteOutputDisplayComponent} from 'rich_text_components/rte-output-display.component'; +import {UndoSnackbarComponent} from 'components/custom-snackbar/undo-snackbar.component'; +import {MatSnackBar, MatSnackBarRef} from '@angular/material/snack-bar'; +import {PlatformFeatureService} from 'services/platform-feature.service'; interface HTMLSchema { type: string; @@ -87,11 +90,21 @@ export interface ActiveContributionDict { suggestion: ActiveSuggestionDict; } +interface PendingSuggestionDict { + target_id: string; + suggestion_id: string; + action_status: string; + reviewer_message: string; + commit_message?: string; +} + enum ExpansionTabType { CONTENT, TRANSLATION, } +const COMMIT_TIMEOUT_DURATION = 30000; // 30 seconds in milliseconds. + @Component({ selector: 'oppia-translation-suggestion-review-modal', templateUrl: './translation-suggestion-review-modal.component.html', @@ -144,6 +157,12 @@ export class TranslationSuggestionReviewModalComponent implements OnInit { isTranslationOverflowing: boolean = false; explorationImagesString: string = ''; suggestionImagesString: string = ''; + queuedSuggestion?: PendingSuggestionDict; + commitTimeout?: NodeJS.Timeout; + removedSuggestion?: ActiveContributionDict; + hasQueuedSuggestion: boolean = false; + currentSnackbarRef?: MatSnackBarRef