From f60516336b49b7979adf944be4e43255d7985ab5 Mon Sep 17 00:00:00 2001 From: Ashwath Kannan <98253080+Ash-2k3@users.noreply.github.com> Date: Mon, 1 Apr 2024 01:56:08 +0530 Subject: [PATCH] Fix #19167: Allow reviewers to undo most recent submitted translation review. (#19954) * Implement Undo feature * Add custom snackbar * Fix non null assertion error * Fix lint errors * Fix CI * Fix CI * Fix CI * Fix CI * Fix CI * Fix CI * Fix conflicts * Add unit tests for deleted tests * Update translation-suggestion-review-modal.component.ts * Fix CI checks * Feature gate the undo feature * Fix flaky behaviour of unit tests * Address Review comments * Address review comments * Address review comments * Update shared-component.module.ts * Add custom snack bars to codeowner files --- .github/CODEOWNERS | 1 + .../undo-snackbar.component.html | 35 + .../undo-snackbar.component.spec.ts | 59 + .../undo-snackbar.component.ts | 38 + .../components/shared-component.module.ts | 4 + ...-suggestion-review-modal.component.spec.ts | 1699 ++++++++++++++--- ...ation-suggestion-review-modal.component.ts | 303 ++- 7 files changed, 1789 insertions(+), 350 deletions(-) create mode 100644 core/templates/components/custom-snackbar/undo-snackbar.component.html create mode 100644 core/templates/components/custom-snackbar/undo-snackbar.component.spec.ts create mode 100644 core/templates/components/custom-snackbar/undo-snackbar.component.ts 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 @@ +
+
+ {{ message }} +
+
+ + +
+
+ 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: ComponentFixture; + let mockSnackBarRef: jasmine.SpyObj>; + + beforeEach(async () => { + mockSnackBarRef = jasmine.createSpyObj('MatSnackBarRef', [ + 'dismiss', + 'dismissWithAction', + ]); + + await TestBed.configureTestingModule({ + declarations: [UndoSnackbarComponent], + providers: [{provide: MatSnackBarRef, useValue: mockSnackBarRef}], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(UndoSnackbarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call dismissWithAction when onUndo is called', () => { + component.onUndo(); + expect(mockSnackBarRef.dismissWithAction).toHaveBeenCalled(); + }); + + it('should call dismiss when onDismiss is called', () => { + component.onDismiss(); + expect(mockSnackBarRef.dismiss).toHaveBeenCalled(); + }); +}); diff --git a/core/templates/components/custom-snackbar/undo-snackbar.component.ts b/core/templates/components/custom-snackbar/undo-snackbar.component.ts new file mode 100644 index 000000000000..ff3eea16601a --- /dev/null +++ b/core/templates/components/custom-snackbar/undo-snackbar.component.ts @@ -0,0 +1,38 @@ +// 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 Component for Custom undo snackbar. + */ + +import {Component} from '@angular/core'; +import {MatSnackBarRef} from '@angular/material/snack-bar'; + +@Component({ + selector: 'app-custom-snackbar', + templateUrl: './undo-snackbar.component.html', +}) +export class UndoSnackbarComponent { + message!: string; + + constructor(private snackBarRef: MatSnackBarRef) {} + + onUndo(): void { + this.snackBarRef.dismissWithAction(); + } + + onDismiss(): void { + this.snackBarRef.dismiss(); + } +} diff --git a/core/templates/components/shared-component.module.ts b/core/templates/components/shared-component.module.ts index 6aba9dd826f6..2baf0ee90491 100644 --- a/core/templates/components/shared-component.module.ts +++ b/core/templates/components/shared-component.module.ts @@ -164,6 +164,7 @@ import {OppiaVisualizationFrequencyTableComponent} from 'visualizations/oppia-vi import {OppiaVisualizationEnumeratedFrequencyTableComponent} from 'visualizations/oppia-visualization-enumerated-frequency-table.directive'; import {RandomSelectorComponent} from 'value_generators/templates/random-selector.component'; import {CopierComponent} from 'value_generators/templates/copier.component'; +import {UndoSnackbarComponent} from './custom-snackbar/undo-snackbar.component'; // Pipes. import {StringUtilityPipesModule} from 'filters/string-utility-filters/string-utility-pipes.module'; @@ -356,6 +357,7 @@ import {DirectivesModule} from 'directives/directives.module'; ComponentOverviewComponent, VisualizationSortedTilesComponent, RteHelperModalComponent, + UndoSnackbarComponent, ], entryComponents: [ @@ -487,6 +489,7 @@ import {DirectivesModule} from 'directives/directives.module'; CopierComponent, RandomSelectorComponent, RteHelperModalComponent, + UndoSnackbarComponent, ], exports: [ @@ -627,6 +630,7 @@ import {DirectivesModule} from 'directives/directives.module'; TranslateModule, VisualizationSortedTilesComponent, RteHelperModalComponent, + UndoSnackbarComponent, ], }) export class SharedComponentsModule {} diff --git a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts index bc323703f01a..2da890689ea5 100644 --- a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts +++ b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts @@ -36,15 +36,39 @@ import {SiteAnalyticsService} from 'services/site-analytics.service'; import {ThreadDataBackendApiService} from 'pages/exploration-editor-page/feedback-tab/services/thread-data-backend-api.service'; import {UserService} from 'services/user.service'; import {UserInfo} from 'domain/user/user-info.model'; +import {OverlayModule} from '@angular/cdk/overlay'; +import { + MatSnackBar, + MatSnackBarModule, + MatSnackBarRef, +} from '@angular/material/snack-bar'; +import {of, Subject} from 'rxjs'; // This throws "TS2307". We need to // 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 {PlatformFeatureService} from 'services/platform-feature.service'; class MockChangeDetectorRef { detectChanges(): void {} } +class MockMatSnackBarRef { + instance = {message: ''}; + afterDismissed = () => of({action: '', dismissedByAction: false}); + onAction = () => of(undefined); + dismiss = () => of(undefined); +} + +class MockPlatformFeatureService { + status = { + CdAllowUndoingTranslationReview: { + isEnabled: false, + }, + }; +} + describe('Translation Suggestion Review Modal Component', function () { let fixture: ComponentFixture; let component: TranslationSuggestionReviewModalComponent; @@ -56,11 +80,17 @@ describe('Translation Suggestion Review Modal Component', function () { let userService: UserService; let activeModal: NgbActiveModal; let changeDetectorRef: MockChangeDetectorRef = new MockChangeDetectorRef(); + let snackBarSpy: jasmine.Spy; + let snackBar: MatSnackBar; + let mockPlatformFeatureService = new MockPlatformFeatureService(); beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - declarations: [TranslationSuggestionReviewModalComponent], + imports: [HttpClientTestingModule, OverlayModule, MatSnackBarModule], + declarations: [ + TranslationSuggestionReviewModalComponent, + UndoSnackbarComponent, + ], providers: [ NgbActiveModal, AlertsService, @@ -73,6 +103,11 @@ describe('Translation Suggestion Review Modal Component', function () { provide: ChangeDetectorRef, useValue: changeDetectorRef, }, + MatSnackBar, + { + provide: PlatformFeatureService, + useValue: mockPlatformFeatureService, + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); @@ -82,6 +117,7 @@ describe('Translation Suggestion Review Modal Component', function () { fixture = TestBed.createComponent( TranslationSuggestionReviewModalComponent ); + snackBar = TestBed.inject(MatSnackBar); component = fixture.componentInstance; activeModal = TestBed.inject(NgbActiveModal); alertsService = TestBed.inject(AlertsService); @@ -98,6 +134,10 @@ describe('Translation Suggestion Review Modal Component', function () { 'audio_language_description' ); + snackBarSpy = spyOn(snackBar, 'openFromComponent').and.returnValue( + new MockMatSnackBarRef() as unknown as MatSnackBarRef + ); + component.contentContainer = new ElementRef({offsetHeight: 150}); component.translationContainer = new ElementRef({offsetHeight: 150}); component.contentPanel = new RteOutputDisplayComponent( @@ -268,7 +308,7 @@ describe('Translation Suggestion Review Modal Component', function () { ); }); - describe('when reviewing suggestion', function () { + describe('when reviewing suggestion when flag CdAllowUndoingTranslationReview is enabled', function () { const reviewable = true; const subheading = 'topic_1 / story_1 / chapter_1'; const suggestion1 = { @@ -360,6 +400,8 @@ describe('Translation Suggestion Review Modal Component', function () { suggestionIdToContribution ); component.editedContent = editedContent; + mockPlatformFeatureService.status.CdAllowUndoingTranslationReview.isEnabled = + true; }); it('should call user service at initialization.', function () { @@ -474,6 +516,8 @@ describe('Translation Suggestion Review Modal Component', function () { ); spyOn(activeModal, 'close'); spyOn(alertsService, 'addSuccessMessage'); + spyOn(component, 'showSnackbar'); + spyOn(component, 'startCommitTimeout'); component.reviewMessage = 'Review message example'; component.translationUpdated = true; @@ -483,6 +527,9 @@ describe('Translation Suggestion Review Modal Component', function () { expect(component.activeSuggestion).toEqual(suggestion2); expect(component.reviewable).toBe(reviewable); expect(component.reviewMessage).toBe(''); + + component.commitQueuedSuggestion(); + // Suggestion 2's exploration_content_html does not match its // content_html. expect(component.hasExplorationContentChanged()).toBe(true); @@ -507,6 +554,8 @@ describe('Translation Suggestion Review Modal Component', function () { component.translationUpdated = false; component.acceptAndReviewNext(); + component.commitQueuedSuggestion(); + expect( siteAnalyticsService.registerContributorDashboardAcceptSuggestion ).toHaveBeenCalledWith('Translation'); @@ -532,7 +581,7 @@ describe('Translation Suggestion Review Modal Component', function () { it( 'should set suggestion review message to auto-generated note when ' + 'suggestion is accepted with edits and no user-supplied review message', - function () { + fakeAsync(() => { component.ngOnInit(); expect(component.activeSuggestionId).toBe('suggestion_1'); expect(component.activeSuggestion).toEqual(suggestion1); @@ -543,6 +592,9 @@ describe('Translation Suggestion Review Modal Component', function () { siteAnalyticsService, 'registerContributorDashboardAcceptSuggestion' ); + spyOn(component, 'resolveSuggestionAndUpdateModal'); + spyOn(component, 'startCommitTimeout'); + spyOn(component, 'showSnackbar'); spyOn( contributionAndReviewService, 'reviewExplorationSuggestion' @@ -564,34 +616,35 @@ describe('Translation Suggestion Review Modal Component', function () { 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(); - } + tick(); + + expect(component.hasQueuedSuggestion).toBeTrue(); + expect(component.queuedSuggestion).toEqual({ + target_id: '1', + suggestion_id: 'suggestion_1', + action_status: 'accept', + commit_message: 'hint section of "StateName" card', + reviewer_message: + '(Note: This suggestion was submitted with ' + 'reviewer edits.)', + }); + }) ); it( - 'should reject suggestion in suggestion modal service when clicking ' + - 'on reject and review next suggestion button', - function () { + 'should accept the queued suggestion when the timer has expired' + + 'review button', + () => { 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' @@ -608,52 +661,99 @@ describe('Translation Suggestion Review Modal Component', function () { return Promise.resolve(successCallback(suggestionId)); } ); - spyOn( - siteAnalyticsService, - 'registerContributorDashboardRejectSuggestion' - ); spyOn(activeModal, 'close'); spyOn(alertsService, 'addSuccessMessage'); + spyOn(component, 'resolveSuggestionAndUpdateModal').and.stub(); + spyOn(component, 'startCommitTimeout').and.stub(); + spyOn(component, 'showSnackbar').and.stub(); - 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.acceptAndReviewNext(); - component.reviewMessage = 'Review message example 2'; - component.translationUpdated = false; - component.rejectAndReviewNext(component.reviewMessage); + expect(component.hasQueuedSuggestion).toBeTrue(); + expect(component.queuedSuggestion).toEqual({ + target_id: '1', + suggestion_id: 'suggestion_1', + action_status: AppConstants.ACTION_ACCEPT_SUGGESTION, + reviewer_message: '', + commit_message: 'hint section of "StateName" card', + }); - expect( - siteAnalyticsService.registerContributorDashboardRejectSuggestion - ).toHaveBeenCalledWith('Translation'); - expect(alertsService.addSuccessMessage).toHaveBeenCalled(); - expect(activeModal.close).toHaveBeenCalledWith([ - 'suggestion_1', - 'suggestion_2', - ]); + component.commitQueuedSuggestion(); } ); + it('should reject the queued suggestion when the timer has expired', () => { + component.ngOnInit(); + + expect(component.activeSuggestionId).toBe('suggestion_1'); + expect(component.activeSuggestion).toEqual(suggestion1); + expect(component.reviewable).toBe(reviewable); + expect(component.reviewMessage).toBe(''); + expect(component.isUndoFeatureEnabled).toBeTrue(); + + 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'); + spyOn(component, 'resolveSuggestionAndUpdateModal').and.stub(); + spyOn(component, 'startCommitTimeout').and.stub(); + spyOn(component, 'showSnackbar').and.stub(); + + component.rejectAndReviewNext('rejected'); + + expect(component.hasQueuedSuggestion).toBeTrue(); + expect(component.queuedSuggestion).toEqual({ + target_id: '1', + suggestion_id: 'suggestion_1', + action_status: AppConstants.ACTION_REJECT_SUGGESTION, + reviewer_message: 'rejected', + }); + + component.commitQueuedSuggestion(); + }); + + it('should undo the queued suggestion when clicked on undo button', () => { + component.ngOnInit(); + expect(component.activeSuggestionId).toBe('suggestion_1'); + expect(component.activeSuggestion).toEqual(suggestion1); + expect(component.reviewable).toBe(reviewable); + expect(component.reviewMessage).toBe(''); + component.resolvedSuggestionIds = ['suggestion_1']; + + component.hasQueuedSuggestion = true; + component.queuedSuggestion = { + target_id: '1', + suggestion_id: 'suggestion_1', + action_status: 'accept', + commit_message: 'hint section of "StateName" card', + reviewer_message: + '(Note: This suggestion was submitted with' + 'reviewer edits.)', + }; + component.removedSuggestion = contribution1; + component.undoReviewAction(); + + expect(component.hasQueuedSuggestion).toBeFalse(); + expect(component.queuedSuggestion).toBe(undefined); + }); + it( 'should allow the reviewer to fix the suggestion if the backend pre' + ' accept/reject validation failed', @@ -665,6 +765,7 @@ describe('Translation Suggestion Review Modal Component', function () { expect(component.activeSuggestion).toEqual(suggestion1); expect(component.reviewable).toBe(reviewable); expect(component.reviewMessage).toBe(''); + spyOn(component, 'revertSuggestionResolution'); spyOn( siteAnalyticsService, 'registerContributorDashboardAcceptSuggestion' @@ -692,57 +793,96 @@ describe('Translation Suggestion Review Modal Component', function () { spyOn(alertsService, 'addWarning'); component.reviewMessage = 'Review message example'; - component.acceptAndReviewNext(); + component.queuedSuggestion = { + target_id: '1', + suggestion_id: 'suggestion_1', + action_status: 'accept', + commit_message: 'hint section of "StateName" card', + reviewer_message: + '(Note: This suggestion was submitted with' + 'reviewer edits.)', + }; + component.hasQueuedSuggestion = true; + component.commitQueuedSuggestion(); + + expect(component.revertSuggestionResolution).toHaveBeenCalled(); + } + ); - 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) - ); + it('should show the pop up bar when suggestion is queued', () => { + spyOn(component, 'commitQueuedSuggestion').and.callThrough(); + component.hasQueuedSuggestion = true; + component.showSnackbar(); - component.reviewMessage = 'Edited review message example'; - component.rejectAndReviewNext(component.reviewMessage); + expect(snackBarSpy.calls.mostRecent().returnValue.instance.message).toBe( + 'Suggestion queued' + ); + }); - 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 commit the queued suggestion when' + ' the snackbar is dismissed', + () => { + const commitQueuedSuggestionSpy = spyOn( + component, + 'commitQueuedSuggestion' + ).and.callThrough(); + + let afterDismissedObservable = new Subject(); + let snackBarRefMock = { + instance: {message: ''}, + afterDismissed: () => afterDismissedObservable.asObservable(), + onAction: () => of(null), + }; + + snackBarSpy.and.returnValue(snackBarRefMock); + + component.showSnackbar(); + component.hasQueuedSuggestion = true; + + afterDismissedObservable.next(); + afterDismissedObservable.complete(); + + expect(commitQueuedSuggestionSpy).toHaveBeenCalled(); + } + ); + + it( + 'should start commit timeout when clicking on accept and review' + + 'next suggestion button', + () => { + spyOn(window, 'setTimeout'); + spyOn(component, 'commitQueuedSuggestion'); + component.commitTimeout = undefined; + + component.startCommitTimeout(); + + expect(window.setTimeout).toHaveBeenCalled(); + const timeoutCallback = ( + window.setTimeout as unknown as jasmine.Spy + ).calls.mostRecent().args[0]; + expect(typeof timeoutCallback).toBe('function'); + + timeoutCallback(); + + expect(component.commitQueuedSuggestion).toHaveBeenCalled(); } ); + it('should remove suggestion_id from resolvedSuggestionIds if it exists', () => { + component.ngOnInit(); + component.resolvedSuggestionIds = ['suggestion_1', 'suggestion_2']; + component.queuedSuggestion = { + suggestion_id: 'suggestion_1', + action_status: 'accept', + target_id: '1', + reviewer_message: '', + }; + component.removedSuggestion = contribution1; + + component.revertSuggestionResolution(); + + expect(component.resolvedSuggestionIds).toEqual(['suggestion_2']); + }); + it( 'should cancel suggestion in suggestion modal service when clicking ' + 'on cancel suggestion button', @@ -885,8 +1025,8 @@ describe('Translation Suggestion Review Modal Component', function () { }); }); - describe('when viewing suggestion', function () { - const reviewable = false; + describe('when reviewing suggestions with deleted opportunites when flag CdAllowUndoingTranslationReview is enabled', function () { + const reviewable = true; const subheading = 'topic_1 / story_1 / chapter_1'; const suggestion1 = { @@ -923,31 +1063,10 @@ describe('Translation Suggestion Review Modal Component', function () { language_code: 'language_code', }, exploration_content_html: 'Translation', + status: 'rejected', author_name: 'author_name', language_code: 'language_code', last_updated_msecs: 1559074000000, - status: 'status', - target_type: 'target_type', - }; - const suggestion3Obsolete = { - 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', }; @@ -959,27 +1078,15 @@ describe('Translation Suggestion Review Modal Component', function () { chapter_title: 'chapter_1', }, }; - const contribution2 = { + + const deletedContribution = { suggestion: suggestion2, - details: { - topic_name: 'topic_2', - story_title: 'story_2', - chapter_title: 'chapter_2', - }, - }; - const contribution3 = { - suggestion: suggestion3Obsolete, - details: { - topic_name: 'topic_3', - story_title: 'story_3', - chapter_title: 'chapter_3', - }, + details: null, }; const suggestionIdToContribution = { suggestion_1: contribution1, - suggestion_2: contribution2, - suggestion_3: contribution3, + suggestion_deleted: deletedContribution, }; beforeEach(() => { @@ -989,35 +1096,957 @@ describe('Translation Suggestion Review Modal Component', function () { component.suggestionIdToContribution = angular.copy( suggestionIdToContribution ); + mockPlatformFeatureService.status.CdAllowUndoingTranslationReview.isEnabled = + true; + component.ngOnInit(); }); - 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' + 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'); + + spyOn(component, 'startCommitTimeout'); + spyOn(component, 'showSnackbar'); + spyOn(component, 'resolveSuggestionAndUpdateModal'); + + component.reviewMessage = 'Review message example'; + component.rejectAndReviewNext(component.reviewMessage); + component.commitQueuedSuggestion(); + + 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.' + ); + } + ); + }); + + describe('when reviewing suggestion when flag CdAllowUndoingTranslationReview is disabled', function () { + const reviewable = true; + const subheading = 'topic_1 / story_1 / chapter_1'; + const suggestion1 = { + author_name: 'author_name', + language_code: 'language_code', + last_updated_msecs: 1559074000000, + status: 'status', + suggestion_id: 'suggestion_1', + target_id: '1', + 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

 

', + }; + + 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\n

content2

', + '

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; + isUndoFeatureEnabled: boolean = false; @Input() altTextIsDisplayed: boolean = false; @ViewChild('contentPanel') @@ -185,10 +204,14 @@ export class TranslationSuggestionReviewModalComponent implements OnInit { private siteAnalyticsService: SiteAnalyticsService, private threadDataBackendApiService: ThreadDataBackendApiService, private userService: UserService, - private validatorsService: ValidatorsService + private validatorsService: ValidatorsService, + private snackBar: MatSnackBar, + private platformFeatureService: PlatformFeatureService ) {} ngOnInit(): void { + this.isUndoFeatureEnabled = + this.platformFeatureService.status.CdAllowUndoingTranslationReview.isEnabled; this.activeSuggestionId = this.initialSuggestionId; this.activeContribution = this.suggestionIdToContribution[this.activeSuggestionId]; @@ -427,73 +450,84 @@ export class TranslationSuggestionReviewModalComponent implements OnInit { } resolveSuggestionAndUpdateModal(): void { - this.resolvedSuggestionIds.push(this.activeSuggestionId); + if (this.isUndoFeatureEnabled) { + if (this.queuedSuggestion) { + this.resolvedSuggestionIds.push(this.queuedSuggestion.suggestion_id); + + // Resolved contributions don't need to be displayed in the modal. + this.removedSuggestion = + this.allContributions[this.queuedSuggestion?.suggestion_id]; + delete this.allContributions[this.queuedSuggestion?.suggestion_id]; + + // If the reviewed item was the last item, close the modal. + if (this.lastSuggestionToReview || this.isLastItem) { + this.commitQueuedSuggestion(); + this.activeModal.close(this.resolvedSuggestionIds); + return; + } + } + this.goToNextItem(); + } else { + this.resolvedSuggestionIds.push(this.activeSuggestionId); - // Resolved contributions don't need to be displayed in the modal. - delete this.allContributions[this.activeSuggestionId]; + // Resolved contributions don't need to be displayed in the modal. + delete this.allContributions[this.activeSuggestionId]; - // If the reviewed item was the last item, close the modal. - if (this.lastSuggestionToReview || this.isLastItem) { - this.activeModal.close(this.resolvedSuggestionIds); - return; + // If the reviewed item was the last item, close the modal. + if (this.lastSuggestionToReview || this.isLastItem) { + this.activeModal.close(this.resolvedSuggestionIds); + return; + } + this.goToNextItem(); } - this.goToNextItem(); } acceptAndReviewNext(): void { - this.finalCommitMessage = this.generateCommitMessage(); - const reviewMessageForSubmitter = - this.reviewMessage + - (this.translationUpdated - ? (this.reviewMessage.length > 0 ? ': ' : '') + - '(Note: This suggestion was submitted with reviewer edits.)' - : ''); - this.resolvingSuggestion = true; - this.siteAnalyticsService.registerContributorDashboardAcceptSuggestion( - 'Translation' - ); - - this.contributionAndReviewService.reviewExplorationSuggestion( - this.activeSuggestion.target_id, - this.activeSuggestionId, - AppConstants.ACTION_ACCEPT_SUGGESTION, - reviewMessageForSubmitter, - this.finalCommitMessage, - () => { - this.alertsService.clearMessages(); - this.alertsService.addSuccessMessage('Suggestion accepted.'); - this.resolveSuggestionAndUpdateModal(); - }, - errorMessage => { - this.alertsService.clearWarnings(); - this.alertsService.addWarning(`Invalid Suggestion: ${errorMessage}`); - } - ); - } - - rejectAndReviewNext(reviewMessage: string): void { - if ( - this.validatorsService.isValidReviewMessage( - reviewMessage, - /* ShowWarnings= */ true - ) - ) { + if (this.isUndoFeatureEnabled) { + this.finalCommitMessage = this.generateCommitMessage(); + const reviewMessageForSubmitter = + this.reviewMessage + + (this.translationUpdated + ? (this.reviewMessage.length > 0 ? ': ' : '') + + '(Note: This suggestion was submitted with reviewer edits.)' + : ''); this.resolvingSuggestion = true; - this.siteAnalyticsService.registerContributorDashboardRejectSuggestion( + this.siteAnalyticsService.registerContributorDashboardAcceptSuggestion( + 'Translation' + ); + this.queuedSuggestion = { + target_id: this.activeSuggestion.target_id, + suggestion_id: this.activeSuggestionId, + action_status: AppConstants.ACTION_ACCEPT_SUGGESTION, + reviewer_message: reviewMessageForSubmitter, + commit_message: this.finalCommitMessage, + }; + this.hasQueuedSuggestion = true; + this.resolveSuggestionAndUpdateModal(); + this.startCommitTimeout(); + this.showSnackbar(); + } else { + this.finalCommitMessage = this.generateCommitMessage(); + const reviewMessageForSubmitter = + this.reviewMessage + + (this.translationUpdated + ? (this.reviewMessage.length > 0 ? ': ' : '') + + '(Note: This suggestion was submitted with reviewer edits.)' + : ''); + this.resolvingSuggestion = true; + this.siteAnalyticsService.registerContributorDashboardAcceptSuggestion( 'Translation' ); - // In case of rejection, the suggestion is not applied, so there is no - // commit message. Because there is no commit to make. this.contributionAndReviewService.reviewExplorationSuggestion( this.activeSuggestion.target_id, this.activeSuggestionId, - AppConstants.ACTION_REJECT_SUGGESTION, - reviewMessage || this.reviewMessage, - null, + AppConstants.ACTION_ACCEPT_SUGGESTION, + reviewMessageForSubmitter, + this.finalCommitMessage, () => { this.alertsService.clearMessages(); - this.alertsService.addSuccessMessage('Suggestion rejected.'); + this.alertsService.addSuccessMessage('Suggestion accepted.'); this.resolveSuggestionAndUpdateModal(); }, errorMessage => { @@ -504,6 +538,168 @@ export class TranslationSuggestionReviewModalComponent implements OnInit { } } + rejectAndReviewNext(reviewMessage: string): void { + if (this.isUndoFeatureEnabled) { + if ( + this.validatorsService.isValidReviewMessage( + reviewMessage, + /* ShowWarnings= */ true + ) + ) { + this.resolvingSuggestion = true; + this.siteAnalyticsService.registerContributorDashboardRejectSuggestion( + 'Translation' + ); + this.queuedSuggestion = { + target_id: this.activeSuggestion.target_id, + suggestion_id: this.activeSuggestionId, + action_status: AppConstants.ACTION_REJECT_SUGGESTION, + reviewer_message: reviewMessage || this.reviewMessage, + }; + this.hasQueuedSuggestion = true; + this.resolveSuggestionAndUpdateModal(); + this.startCommitTimeout(); + this.showSnackbar(); + } + } else { + if ( + this.validatorsService.isValidReviewMessage( + reviewMessage, + /* ShowWarnings= */ true + ) + ) { + this.resolvingSuggestion = true; + this.siteAnalyticsService.registerContributorDashboardRejectSuggestion( + 'Translation' + ); + + // In case of rejection, the suggestion is not applied, so there is no + // commit message. Because there is no commit to make. + this.contributionAndReviewService.reviewExplorationSuggestion( + this.activeSuggestion.target_id, + this.activeSuggestionId, + AppConstants.ACTION_REJECT_SUGGESTION, + reviewMessage || this.reviewMessage, + null, + () => { + this.alertsService.clearMessages(); + this.alertsService.addSuccessMessage('Suggestion rejected.'); + this.resolveSuggestionAndUpdateModal(); + }, + errorMessage => { + this.alertsService.clearWarnings(); + this.alertsService.addWarning( + `Invalid Suggestion: ${errorMessage}` + ); + } + ); + } + } + } + + revertSuggestionResolution(): void { + // Remove the suggestion ID from resolvedSuggestionIds. + if (this.queuedSuggestion && this.removedSuggestion) { + const index = this.resolvedSuggestionIds.indexOf( + this.queuedSuggestion?.suggestion_id + ); + if (index > -1) { + this.resolvedSuggestionIds.splice(index, 1); + } + + // Add the removed suggestion back to allContributions. + this.allContributions[this.queuedSuggestion?.suggestion_id] = + this.removedSuggestion; + } + } + + startCommitTimeout(): void { + clearTimeout(this.commitTimeout); // Clear existing timeout. + + // Start a new timeout for commit after timeframe. + this.commitTimeout = setTimeout(() => { + this.commitQueuedSuggestion(); + }, COMMIT_TIMEOUT_DURATION); + } + + commitQueuedSuggestion(): void { + if (!this.queuedSuggestion) { + return; + } + this.contributionAndReviewService.reviewExplorationSuggestion( + this.queuedSuggestion.target_id, + this.queuedSuggestion.suggestion_id, + this.queuedSuggestion.action_status, + this.queuedSuggestion.reviewer_message, + this.queuedSuggestion.action_status === 'accept' && + this.queuedSuggestion.commit_message + ? this.queuedSuggestion.commit_message + : null, + // Only include commit_message for accepted suggestions. + () => { + this.alertsService.clearMessages(); + this.alertsService.addSuccessMessage( + `Suggestion ${ + this.queuedSuggestion?.action_status === 'accept' + ? 'accepted' + : 'rejected' + }.` + ); + this.clearQueuedSuggestion(); + }, + errorMessage => { + this.alertsService.clearWarnings(); + this.alertsService.addWarning(`Invalid Suggestion: ${errorMessage}`); + this.revertSuggestionResolution(); + } + ); + } + + clearQueuedSuggestion(): void { + this.queuedSuggestion = undefined; + this.hasQueuedSuggestion = false; + } + + undoReviewAction(): void { + clearTimeout(this.commitTimeout); // Clear the commit timeout. + if (this.queuedSuggestion) { + const indexToRemove = this.resolvedSuggestionIds.indexOf( + this.queuedSuggestion.suggestion_id + ); + if (indexToRemove !== -1) { + this.resolvedSuggestionIds.splice(indexToRemove, 1); + if (this.removedSuggestion) { + this.allContributions[this.queuedSuggestion.suggestion_id] = + this.removedSuggestion; + } + } + } + this.clearQueuedSuggestion(); + } + + showSnackbar(): void { + this.currentSnackbarRef = + this.snackBar.openFromComponent( + UndoSnackbarComponent, + { + duration: COMMIT_TIMEOUT_DURATION, + verticalPosition: 'bottom', + horizontalPosition: 'right', + } + ); + this.currentSnackbarRef.instance.message = 'Suggestion queued'; + + this.currentSnackbarRef.onAction().subscribe(() => { + this.undoReviewAction(); + }); + + this.currentSnackbarRef.afterDismissed().subscribe(() => { + if (this.hasQueuedSuggestion) { + this.commitQueuedSuggestion(); + } + }); + } + // Returns whether the active suggestion's exploration_content_html // differs from the content_html of the suggestion's change object. hasExplorationContentChanged(): boolean { @@ -551,6 +747,7 @@ export class TranslationSuggestionReviewModalComponent implements OnInit { } cancel(): void { + this.commitQueuedSuggestion(); this.activeModal.close(this.resolvedSuggestionIds); }