diff --git a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html index f4f6f0f1a..855f1cf74 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html +++ b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html @@ -97,7 +97,13 @@

{{ 'preprints.details.decision.decline.label' | transl
- +
- +
+ +
+ + +
diff --git a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts index e0e30c6f3..a232fac62 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts @@ -13,6 +13,7 @@ import { SubmitRequestsDecision, SubmitReviewsDecision, } from '@osf/features/preprints/store/preprint'; +import { ToastService } from '@osf/shared/services/toast.service'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; @@ -21,6 +22,7 @@ import { REVIEW_ACTION_MOCK } from '@testing/mocks/review-action.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; import { PreprintMakeDecisionComponent } from './preprint-make-decision.component'; @@ -44,6 +46,7 @@ describe('PreprintMakeDecisionComponent', () => { providers: [ provideOSFCore(), MockProvider(Router, routerMock), + MockProvider(ToastService, ToastServiceMock.simple()), provideMockStore({ signals: [{ selector: PreprintSelectors.getPreprint, value: mockPreprint }], }), diff --git a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts index 3ae7f4608..93d4eb0db 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts @@ -26,7 +26,9 @@ import { SubmitReviewsDecision, } from '@osf/features/preprints/store/preprint'; import { InputLimits } from '@osf/shared/constants/input-limits.const'; +import { ReviewActionTrigger } from '@osf/shared/enums/trigger-action.enum'; import { StringOrNull } from '@osf/shared/helpers/types.helper'; +import { ToastService } from '@osf/shared/services/toast.service'; @Component({ selector: 'osf-preprint-make-decision', @@ -38,6 +40,7 @@ import { StringOrNull } from '@osf/shared/helpers/types.helper'; export class PreprintMakeDecisionComponent { private readonly translateService = inject(TranslateService); private readonly router = inject(Router); + private readonly toastService = inject(ToastService); private readonly actions = createDispatchMap({ submitReviewsDecision: SubmitReviewsDecision, @@ -53,7 +56,7 @@ export class PreprintMakeDecisionComponent { dialogVisible = false; didValidate = signal(false); - decision = signal(ReviewsState.Accepted); + decision = signal(ReviewsState.Accepted); initialReviewerComment = signal(null); reviewerComment = signal(null); requestDecisionJustification = signal(null); @@ -61,6 +64,7 @@ export class PreprintMakeDecisionComponent { readonly decisionCommentLimit = InputLimits.decisionComment.maxLength; readonly ReviewsState = ReviewsState; + readonly ReviewActionTrigger = ReviewActionTrigger; labelDecisionButton = computed(() => { const preprint = this.preprint(); @@ -99,7 +103,11 @@ export class PreprintMakeDecisionComponent { const preprint = this.preprint(); const reviewsState = preprint?.reviewsState; - if (this.isPendingWithdrawal() || reviewsState === ReviewsState.Pending) { + if ( + this.decision() === ReviewActionTrigger.ReportSpam || + this.isPendingWithdrawal() || + reviewsState === ReviewsState.Pending + ) { return 'preprints.details.decision.submitButton.submitDecision'; } @@ -231,6 +239,11 @@ export class PreprintMakeDecisionComponent { const preprint = this.preprint(); if (!preprint) return; + if (this.decision() === ReviewActionTrigger.ReportSpam) { + this.reportSpam(); + return; + } + let trigger; if (preprint.reviewsState !== ReviewsState.Pending && this.commentEdited() && !this.decisionChanged()) { // If the submission is not pending, @@ -306,6 +319,32 @@ export class PreprintMakeDecisionComponent { } } + decisionToggled() { + if (this.decision() === ReviewActionTrigger.ReportSpam) { + this.reviewerComment.set(null); + } else { + this.reviewerComment.set(this.initialReviewerComment()); + } + } + + private reportSpam() { + const preprint = this.preprint(); + if (!preprint) return; + + this.saving.set(true); + this.actions + .submitReviewsDecision(ReviewActionTrigger.ReportSpam, this.reviewerComment()?.trim() || null) + .pipe(finalize(() => this.saving.set(false))) + .subscribe({ + next: () => { + this.toastService.showSuccess('preprints.details.decision.reportSpam.success'); + this.dialogVisible = false; + this.decision.set(preprint.reviewsState); + this.reviewerComment.set(this.initialReviewerComment()); + }, + }); + } + requestDecisionToggled() { if (!this.isPendingWithdrawal()) { return; diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts index 760b76ce1..97cc4f9ed 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts @@ -30,6 +30,7 @@ import { PrerenderReadyService } from '@core/services/prerender-ready.service'; import { ClearCurrentProvider } from '@core/store/provider'; import { UserSelectors } from '@core/store/user'; import { ReviewPermissions } from '@osf/shared/enums/review-permissions.enum'; +import { ReviewActionTrigger } from '@osf/shared/enums/trigger-action.enum'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { LoaderService } from '@osf/shared/services/loader.service'; @@ -147,7 +148,8 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { }); latestAction = computed(() => { - const actions = this.reviewActions(); + // banners and the decision dialog describe the last decision, and a spam report is not one + const actions = this.reviewActions().filter((action) => action.trigger !== ReviewActionTrigger.ReportSpam); return actions.length > 0 ? actions[0] : null; }); diff --git a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.html b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.html index 480e1d155..2da584ea9 100644 --- a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.html +++ b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.html @@ -1,5 +1,5 @@
- @for (action of reviewActions(); track $index) { + @for (action of decisionActions(); track $index) {

@switch (action.toState) { @case (SubmissionReviewStatus.Pending) { @@ -63,6 +63,17 @@ }

+ +
+ + +
@@ -92,6 +103,17 @@

{{ 'moderation.makeDecision.forceWithdrawMessage' | translate }}

+ +
+ + +
diff --git a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.spec.ts b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.spec.ts index 8f4d69620..0cd9737e0 100644 --- a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.spec.ts +++ b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.spec.ts @@ -13,11 +13,13 @@ import { RegistrationReviewStates } from '@osf/shared/enums/registration-review- import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; import { ReviewActionTrigger, SchemaResponseActionTrigger } from '@osf/shared/enums/trigger-action.enum'; import { DateAgoPipe } from '@osf/shared/pipes/date-ago.pipe'; +import { ToastService } from '@osf/shared/services/toast.service'; import { MOCK_REGISTRATION_OVERVIEW_MODEL } from '@testing/mocks/registration-overview-model.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; import { RegistrySelectors } from '../../store/registry'; @@ -43,6 +45,7 @@ function setup(overrides: SetupOverrides = {}) { providers: [ provideOSFCore(), provideDynamicDialogRefMock(), + MockProvider(ToastService, ToastServiceMock.simple()), MockProvider(DynamicDialogConfig, { data: { registry, revisionId } }), provideMockStore({ signals: [ diff --git a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts index b823b9a47..7f7b712cf 100644 --- a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts +++ b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts @@ -9,7 +9,7 @@ import { RadioButton } from 'primeng/radiobutton'; import { Textarea } from 'primeng/textarea'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -45,6 +45,9 @@ export class RegistryMakeDecisionComponent { readonly decisionCommentLimit = InputLimits.decisionComment.maxLength; readonly reviewActions = select(RegistrySelectors.getReviewActions); + readonly decisionActions = computed(() => + this.reviewActions().filter((action) => action.trigger !== ReviewActionTrigger.ReportSpam) + ); readonly isSubmitting = select(RegistrySelectors.isReviewActionSubmitting); private readonly actions = createDispatchMap({ submitDecision: SubmitDecision }); @@ -94,12 +97,14 @@ export class RegistryMakeDecisionComponent { } handleSubmission(): void { - const revisionId = this.config.data.revisionId; + const action = this.requestForm.value[ModerationDecisionFormControls.Action] ?? ''; + // a spam report is related to the registration and its submitter, so a revision is never its target + const revisionId = action === ReviewActionTrigger.ReportSpam ? null : this.config.data.revisionId; this.actions .submitDecision( { targetId: revisionId ? revisionId : this.registry.id, - action: this.requestForm.value[ModerationDecisionFormControls.Action] ?? '', + action, comment: this.requestForm.value[ModerationDecisionFormControls.Comment] ?? '', }, !!revisionId diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts index fcf3bbac3..c5bcdde7b 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts @@ -29,6 +29,7 @@ import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-l import { RegistrationReviewStates } from '@osf/shared/enums/registration-review-states.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; +import { ReviewActionTrigger } from '@osf/shared/enums/trigger-action.enum'; import { toCamelCase } from '@osf/shared/helpers/camel-case'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { LoaderService } from '@osf/shared/services/loader.service'; @@ -230,6 +231,12 @@ export class RegistryOverviewComponent implements OnInit, OnDestroy { this.toastService.showSuccess(`moderation.makeDecision.${action}Success`); } + // a spam report leaves the registration untouched, and the reload below drops the + // moderator query param, which would hide the 'Make decision' button + if (data.action === ReviewActionTrigger.ReportSpam) { + return; + } + const currentUrl = this.router.url.split('?')[0]; this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => { this.router.navigateByUrl(currentUrl); diff --git a/src/app/shared/enums/trigger-action.enum.ts b/src/app/shared/enums/trigger-action.enum.ts index 2740af5ca..29c3d7c1a 100644 --- a/src/app/shared/enums/trigger-action.enum.ts +++ b/src/app/shared/enums/trigger-action.enum.ts @@ -7,6 +7,7 @@ export enum ReviewActionTrigger { AcceptWithdrawal = 'accept_withdrawal', RejectWithdrawal = 'reject_withdrawal', RequestEmbargoTermination = 'request_embargo_termination', + ReportSpam = 'report_spam', } export enum SchemaResponseActionTrigger { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 26437b779..a3eaf1785 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -977,6 +977,8 @@ "rejectWithdrawalMessage": "Submission will remain fully public", "remarksPlaceholder": "Add remarks to registration admins", "removeSuccess": "Submission has been withdrawn successfully", + "reportSpam": "Report spam", + "reportSpamSuccess": "Submission has been reported as spam", "submitDecision": "Submit decision", "withdrawRequest": "Withdraw Request", "withdrawRequestMessage": "Submission will be withdrawn from the collection and will no longer appear in search results." @@ -1332,6 +1334,10 @@ "post": "Submission will be removed from search results and made private.", "pre": "Submission will not appear in search results and will remain private." }, + "reportSpam": { + "label": "Report spam", + "success": "Submission has been reported as spam" + }, "settings": { "comments": { "private": "Comments are not visible to contributors",