(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",