diff --git a/src/app/core/components/nav-menu/nav-menu.component.ts b/src/app/core/components/nav-menu/nav-menu.component.ts index 6aecdf081..a4415dad9 100644 --- a/src/app/core/components/nav-menu/nav-menu.component.ts +++ b/src/app/core/components/nav-menu/nav-menu.component.ts @@ -44,6 +44,7 @@ export class NavMenuComponent { isProject: this.isProjectRoute() && !this.isRegistryRoute() && !this.isPreprintRoute(), isRegistry: this.isRegistryRoute(), isPreprint: this.isPreprintRoute(), + preprintReviewsPageVisible: this.canUserViewReviews(), isCollections: this.isCollectionsRoute() || false, currentUrl: this.router.url, }; @@ -69,6 +70,7 @@ export class NavMenuComponent { protected readonly isCollectionsRoute = computed(() => this.currentRoute().isCollectionsWithId); protected readonly isRegistryRoute = computed(() => this.currentRoute().isRegistryRoute); protected readonly isPreprintRoute = computed(() => this.currentRoute().isPreprintRoute); + protected readonly canUserViewReviews = select(UserSelectors.getCanViewReviews); private getRouteInfo() { const urlSegments = this.router.url.split('/').filter((segment) => segment); diff --git a/src/app/core/helpers/nav-menu.helper.ts b/src/app/core/helpers/nav-menu.helper.ts index 924e6616a..178988118 100644 --- a/src/app/core/helpers/nav-menu.helper.ts +++ b/src/app/core/helpers/nav-menu.helper.ts @@ -140,6 +140,9 @@ function updatePreprintMenuItem(item: MenuItem, ctx: RouteContext): MenuItem { } return { ...subItem, visible: false, expanded: false }; } + if (subItem.id === 'preprints-moderation') { + return { ...subItem, visible: ctx.preprintReviewsPageVisible }; + } return subItem; }); diff --git a/src/app/core/models/route-context.model.ts b/src/app/core/models/route-context.model.ts index 9507f5eb7..780ac929b 100644 --- a/src/app/core/models/route-context.model.ts +++ b/src/app/core/models/route-context.model.ts @@ -4,6 +4,7 @@ export interface RouteContext { isProject: boolean; isRegistry: boolean; isPreprint: boolean; + preprintReviewsPageVisible?: boolean; isCollections: boolean; currentUrl?: string; } diff --git a/src/app/core/store/user/user.selectors.ts b/src/app/core/store/user/user.selectors.ts index 9552da4ca..88ab9dd9d 100644 --- a/src/app/core/store/user/user.selectors.ts +++ b/src/app/core/store/user/user.selectors.ts @@ -63,6 +63,11 @@ export class UserSelectors { return !!state.currentUser.data?.isModerator; } + @Selector([UserState]) + static getCanViewReviews(state: UserStateModel): boolean { + return state.currentUser.data?.canViewReviews || false; + } + @Selector([UserState]) static isAuthenticated(state: UserStateModel): boolean { return !!state.currentUser.data || !!localStorage.getItem('currentUser'); diff --git a/src/app/features/moderation/components/moderators-list/moderators-list.component.html b/src/app/features/moderation/components/moderators-list/moderators-list.component.html index 63d1c64bb..93c78d295 100644 --- a/src/app/features/moderation/components/moderators-list/moderators-list.component.html +++ b/src/app/features/moderation/components/moderators-list/moderators-list.component.html @@ -1,24 +1,26 @@
-
- -
+ -
- -
+ @if (isCurrentUserAdminModerator()) { +
+ +
+ }
+ />
diff --git a/src/app/features/moderation/components/moderators-list/moderators-list.component.ts b/src/app/features/moderation/components/moderators-list/moderators-list.component.ts index 3581085f5..5c575a75e 100644 --- a/src/app/features/moderation/components/moderators-list/moderators-list.component.ts +++ b/src/app/features/moderation/components/moderators-list/moderators-list.component.ts @@ -7,12 +7,23 @@ import { DialogService } from 'primeng/dynamicdialog'; import { debounceTime, distinctUntilChanged, filter, forkJoin, map, of, skip } from 'rxjs'; -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit, Signal, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + OnInit, + Signal, + signal, +} from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormControl } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; -import { AddModeratorType } from '@osf/features/moderation/enums'; +import { UserSelectors } from '@core/store/user'; +import { AddModeratorType, ModeratorPermission } from '@osf/features/moderation/enums'; import { ModeratorDialogAddModel, ModeratorModel } from '@osf/features/moderation/models'; import { AddModerator, @@ -58,6 +69,17 @@ export class ModeratorsListComponent implements OnInit { moderators = signal([]); initialModerators = select(ModeratorsSelectors.getModerators); isModeratorsLoading = select(ModeratorsSelectors.isModeratorsLoading); + currentUser = select(UserSelectors.getCurrentUser); + + isCurrentUserAdminModerator = computed(() => { + const currentUserId = this.currentUser()?.id; + const initialModerators = this.initialModerators(); + if (!currentUserId) return false; + + return initialModerators.some((moderator: ModeratorModel) => { + return moderator.userId === currentUserId && moderator.permission === ModeratorPermission.Admin; + }); + }); protected actions = createDispatchMap({ loadModerators: LoadModerators, diff --git a/src/app/features/moderation/components/moderators-table/moderators-table.component.html b/src/app/features/moderation/components/moderators-table/moderators-table.component.html index 4b5556514..6930ab39c 100644 --- a/src/app/features/moderation/components/moderators-table/moderators-table.component.html +++ b/src/app/features/moderation/components/moderators-table/moderators-table.component.html @@ -38,14 +38,25 @@
- + @if (isCurrentUserAdminModerator()) { + + } @else { + @switch (item.permission) { + @case (ModeratorPermission.Admin) { + {{ 'moderation.moderatorPermissions.administrator' | translate }} + } + @case (ModeratorPermission.Moderator) { + {{ 'moderation.moderatorPermissions.moderator' | translate }} + } + } + }
@@ -79,8 +90,16 @@ - - + @if (isCurrentUserAdminModerator() || currentUserId() === item.id) { + + } } @else { diff --git a/src/app/features/moderation/components/moderators-table/moderators-table.component.ts b/src/app/features/moderation/components/moderators-table/moderators-table.component.ts index af7bcccad..1cfed3308 100644 --- a/src/app/features/moderation/components/moderators-table/moderators-table.component.ts +++ b/src/app/features/moderation/components/moderators-table/moderators-table.component.ts @@ -9,6 +9,7 @@ import { ChangeDetectionStrategy, Component, inject, input, output, signal } fro import { FormsModule } from '@angular/forms'; import { MODERATION_PERMISSIONS } from '@osf/features/moderation/constants'; +import { ModeratorPermission } from '@osf/features/moderation/enums'; import { ModeratorModel } from '@osf/features/moderation/models'; import { EducationHistoryDialogComponent, @@ -29,6 +30,8 @@ import { TableParameters } from '@osf/shared/models'; export class ModeratorsTableComponent { items = input([]); isLoading = input(false); + currentUserId = input.required(); + isCurrentUserAdminModerator = input.required(); update = output(); remove = output(); @@ -38,6 +41,7 @@ export class ModeratorsTableComponent { protected readonly tableParams = signal({ ...MY_PROJECTS_TABLE_PARAMS }); protected readonly permissionsOptions = MODERATION_PERMISSIONS; + protected readonly ModeratorPermission = ModeratorPermission; skeletonData: ModeratorModel[] = Array.from({ length: 3 }, () => ({}) as ModeratorModel); diff --git a/src/app/features/moderation/components/preprint-submissions/preprint-submissions.component.ts b/src/app/features/moderation/components/preprint-submissions/preprint-submissions.component.ts index fef358003..6d76bd71e 100644 --- a/src/app/features/moderation/components/preprint-submissions/preprint-submissions.component.ts +++ b/src/app/features/moderation/components/preprint-submissions/preprint-submissions.component.ts @@ -113,7 +113,7 @@ export class PreprintSubmissionsComponent implements OnInit { } navigateToPreprint(item: PreprintSubmission) { - this.router.navigate(['/preprints/', item.id, 'overview'], { queryParams: { mode: 'moderator' } }); + this.router.navigate(['/preprints/', this.providerId(), item.id], { queryParams: { mode: 'moderator' } }); } private getStatusFromQueryParams() { diff --git a/src/app/features/moderation/components/preprint-withdrawal-submissions/preprint-withdrawal-submissions.component.ts b/src/app/features/moderation/components/preprint-withdrawal-submissions/preprint-withdrawal-submissions.component.ts index c5d2da37c..a0b6fcd3e 100644 --- a/src/app/features/moderation/components/preprint-withdrawal-submissions/preprint-withdrawal-submissions.component.ts +++ b/src/app/features/moderation/components/preprint-withdrawal-submissions/preprint-withdrawal-submissions.component.ts @@ -110,7 +110,7 @@ export class PreprintWithdrawalSubmissionsComponent implements OnInit { } navigateToPreprint(item: PreprintWithdrawalSubmission) { - this.router.navigate(['/preprints/', item.preprintId, 'overview'], { queryParams: { mode: 'moderator' } }); + this.router.navigate(['/preprints/', this.providerId(), item.preprintId], { queryParams: { mode: 'moderator' } }); } private getStatusFromQueryParams() { diff --git a/src/app/features/moderation/constants/submission.const.ts b/src/app/features/moderation/constants/submission.const.ts index 9ded27f00..377a9a34d 100644 --- a/src/app/features/moderation/constants/submission.const.ts +++ b/src/app/features/moderation/constants/submission.const.ts @@ -65,13 +65,13 @@ export const WITHDRAWAL_SUBMISSION_REVIEW_OPTIONS: SubmissionReviewOption[] = [ { value: SubmissionReviewStatus.Accepted, icon: 'fas fa-circle-check', - label: 'moderation.submissionReviewStatus.accepted', + label: 'moderation.submissionReviewStatus.approved', count: 0, }, { value: SubmissionReviewStatus.Rejected, icon: 'fas fa-circle-minus', - label: 'moderation.submissionReviewStatus.rejected', + label: 'moderation.submissionReviewStatus.declined', count: 0, }, ]; diff --git a/src/app/features/moderation/models/index.ts b/src/app/features/moderation/models/index.ts index 897e8984a..302a37ead 100644 --- a/src/app/features/moderation/models/index.ts +++ b/src/app/features/moderation/models/index.ts @@ -17,5 +17,4 @@ export * from './registry-json-api.model'; export * from './registry-moderation.model'; export * from './review-action.model'; export * from './review-action-json-api.model'; -export * from './submission.model'; export * from './submission-review-option.model'; diff --git a/src/app/features/moderation/models/submission.model.ts b/src/app/features/moderation/models/submission.model.ts deleted file mode 100644 index f29815543..000000000 --- a/src/app/features/moderation/models/submission.model.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface CollectionSubmission { - id: string; - title: string; - reviewStatus: string; - dateSubmitted: string; - submittedBy: string; -} diff --git a/src/app/features/moderation/services/moderators.service.ts b/src/app/features/moderation/services/moderators.service.ts index 4db0f2ab6..9c554e713 100644 --- a/src/app/features/moderation/services/moderators.service.ts +++ b/src/app/features/moderation/services/moderators.service.ts @@ -21,7 +21,7 @@ export class ModeratorsService { private readonly urlMap = new Map([ [ResourceType.Collection, 'providers/collections'], [ResourceType.Registration, 'providers/registrations'], - [ResourceType.Preprint, 'preprint_providers'], + [ResourceType.Preprint, 'providers/preprints'], ]); getModerators(resourceId: string, resourceType: ResourceType): Observable { diff --git a/src/app/features/moderation/services/preprint-moderation.service.ts b/src/app/features/moderation/services/preprint-moderation.service.ts index 3dce5ceac..2e827bb68 100644 --- a/src/app/features/moderation/services/preprint-moderation.service.ts +++ b/src/app/features/moderation/services/preprint-moderation.service.ts @@ -29,7 +29,7 @@ export class PreprintModerationService { private readonly jsonApiService = inject(JsonApiService); getPreprintProviders(): Observable { - const baseUrl = `${environment.apiUrl}/preprint_providers/?filter[permissions]=view_actions,set_up_moderation`; + const baseUrl = `${environment.apiUrl}/providers/preprints/?filter[permissions]=view_actions,set_up_moderation`; return this.jsonApiService .get>(baseUrl) diff --git a/src/app/features/preprints/components/index.ts b/src/app/features/preprints/components/index.ts index 2a063528f..9f9ae08df 100644 --- a/src/app/features/preprints/components/index.ts +++ b/src/app/features/preprints/components/index.ts @@ -6,6 +6,7 @@ export { PreprintsInstitutionFilterComponent } from './filters/preprints-institu export { PreprintsLicenseFilterComponent } from './filters/preprints-license-filter/preprints-license-filter.component'; export { AdditionalInfoComponent } from './preprint-details/additional-info/additional-info.component'; export { GeneralInformationComponent } from './preprint-details/general-information/general-information.component'; +export { ModerationStatusBannerComponent } from './preprint-details/moderation-status-banner/moderation-status-banner.component'; export { PreprintFileSectionComponent } from './preprint-details/preprint-file-section/preprint-file-section.component'; export { ShareAndDownloadComponent } from './preprint-details/share-and-downlaod/share-and-download.component'; export { StatusBannerComponent } from './preprint-details/status-banner/status-banner.component'; @@ -19,6 +20,8 @@ export { PreprintsFilterChipsComponent } from '@osf/features/preprints/component export { PreprintsResourcesComponent } from '@osf/features/preprints/components/filters/preprints-resources/preprints-resources.component'; export { PreprintsResourcesFiltersComponent } from '@osf/features/preprints/components/filters/preprints-resources-filters/preprints-resources-filters.component'; export { PreprintsSubjectFilterComponent } from '@osf/features/preprints/components/filters/preprints-subject-filter/preprints-subject-filter.component'; +export { MakeDecisionComponent } from '@osf/features/preprints/components/preprint-details/make-decision/make-decision.component'; +export { PreprintTombstoneComponent } from '@osf/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component'; export { WithdrawDialogComponent } from '@osf/features/preprints/components/preprint-details/withdraw-dialog/withdraw-dialog.component'; export { FileStepComponent } from '@osf/features/preprints/components/stepper/file-step/file-step.component'; export { MetadataStepComponent } from '@osf/features/preprints/components/stepper/metadata-step/metadata-step.component'; diff --git a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.html b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.html index 1408e18e3..5accbda77 100644 --- a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.html +++ b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.html @@ -13,12 +13,22 @@

{{ 'preprints.preprintStepper.review.sections.metadata.publicationCitation' @if (preprintValue.originalPublicationDate) {
-

{{ 'preprints.preprintStepper.review.sections.metadata.publicationDate' | translate }}

+

{{ 'preprints.details.originalPublicationDate' | translate }}

{{ preprintValue.originalPublicationDate | date: 'MMM d, y, h:mm a' }}
} + @if (preprintValue.doi) { +
+

{{ 'preprints.details.publicationDoi' | translate }}

+ + + {{ preprint()?.articleDoiLink }} + +
+ } +

{{ 'preprints.preprintStepper.review.sections.metadata.license' | translate }}

diff --git a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html index 196f9e4e2..5b4ea4c1e 100644 --- a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html +++ b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html @@ -19,6 +19,16 @@

{{ 'preprints.preprintStepper.review.sections.metadata.affiliatedInstitution

} + @if (preprintValue.nodeId) { +
+

{{ 'preprints.details.supplementalMaterials' | translate }}

+ + {{ nodeLink() }} + + +
+ } +

{{ 'preprints.preprintStepper.review.sections.metadata.authors' | translate }}

@@ -108,7 +118,10 @@

} - + } diff --git a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.ts b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.ts index 842cfe221..757e4d53e 100644 --- a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.ts +++ b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.ts @@ -5,21 +5,33 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { Skeleton } from 'primeng/skeleton'; -import { ChangeDetectionStrategy, Component, computed, effect, input, OnDestroy, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, input, OnDestroy, output, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; import { PreprintDoiSectionComponent } from '@osf/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component'; import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums'; import { PreprintProviderDetails } from '@osf/features/preprints/models'; import { FetchPreprintById, PreprintSelectors } from '@osf/features/preprints/store/preprint'; -import { TruncatedTextComponent } from '@shared/components'; +import { IconComponent, TruncatedTextComponent } from '@shared/components'; import { ResourceType } from '@shared/enums'; import { Institution } from '@shared/models'; import { ContributorsSelectors, GetAllContributors, ResetContributorsState } from '@shared/stores'; +import { environment } from 'src/environments/environment'; + @Component({ selector: 'osf-preprint-general-information', - imports: [Card, TranslatePipe, TruncatedTextComponent, Skeleton, FormsModule, PreprintDoiSectionComponent], + imports: [ + Card, + TranslatePipe, + TruncatedTextComponent, + Skeleton, + FormsModule, + PreprintDoiSectionComponent, + RouterLink, + IconComponent, + ], templateUrl: './general-information.component.html', styleUrl: './general-information.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -33,7 +45,10 @@ export class GeneralInformationComponent implements OnDestroy { resetContributorsState: ResetContributorsState, fetchPreprintById: FetchPreprintById, }); + protected readonly environment = environment; + preprintProvider = input.required(); + preprintVersionSelected = output(); preprint = select(PreprintSelectors.getPreprint); isPreprintLoading = select(PreprintSelectors.isPreprintLoading); @@ -49,6 +64,10 @@ export class GeneralInformationComponent implements OnDestroy { skeletonData = Array.from({ length: 5 }, () => null); + nodeLink = computed(() => { + return `${environment.webUrl}/${this.preprint()?.nodeId}`; + }); + constructor() { effect(() => { const preprint = this.preprint(); diff --git a/src/app/features/preprints/components/preprint-details/make-decision/make-decision.component.html b/src/app/features/preprints/components/preprint-details/make-decision/make-decision.component.html new file mode 100644 index 000000000..9c4cb5bbd --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/make-decision/make-decision.component.html @@ -0,0 +1,148 @@ + + + +
+ @if (isPendingWithdrawal()) { +
+
+ + + +
+ +
+ + + +
+
+ +
+ + + + @if (didValidate() && decision() === ReviewsState.Rejected) { + + {{ requestDecisionJustificationErrorMessage() }} + + } +
+ +
+ +
+ } @else { + @if (preprint()?.reviewsState === ReviewsState.Withdrawn) { + {{ latestAction()!.comment }} + } @else { +
+
    +
  • {{ settingsComments() | translate }}
  • + @if (!provider().reviewsCommentsPrivate) { +
  • {{ settingsNames() | translate }}
  • + } +
  • {{ settingsModeration() | translate }}
  • +
+
+ +
+
+ + + +
+ +
+ + + +
+
+ +
+ + @if (commentExceedsLimit()) { + + {{ commentLengthErrorMessage() }} + + } +
+ +
+ @if (preprint()?.reviewsState !== ReviewsState.Pending) { + + } + +
+ } + } +
+
diff --git a/src/app/features/preprints/components/preprint-details/make-decision/make-decision.component.scss b/src/app/features/preprints/components/preprint-details/make-decision/make-decision.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/preprints/components/preprint-details/make-decision/make-decision.component.spec.ts b/src/app/features/preprints/components/preprint-details/make-decision/make-decision.component.spec.ts new file mode 100644 index 000000000..54701ba1e --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/make-decision/make-decision.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MakeDecisionComponent } from './make-decision.component'; + +describe.skip('MakeDecisionComponent', () => { + let component: MakeDecisionComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MakeDecisionComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(MakeDecisionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/preprints/components/preprint-details/make-decision/make-decision.component.ts b/src/app/features/preprints/components/preprint-details/make-decision/make-decision.component.ts new file mode 100644 index 000000000..33ce709b6 --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/make-decision/make-decision.component.ts @@ -0,0 +1,316 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Dialog } from 'primeng/dialog'; +import { Message } from 'primeng/message'; +import { RadioButton } from 'primeng/radiobutton'; +import { Textarea } from 'primeng/textarea'; +import { Tooltip } from 'primeng/tooltip'; + +import { TitleCasePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; + +import { ReviewAction } from '@osf/features/moderation/models'; +import { decisionExplanation, decisionSettings, formInputLimits } from '@osf/features/preprints/constants'; +import { ProviderReviewsWorkflow, ReviewsState } from '@osf/features/preprints/enums'; +import { PreprintProviderDetails, PreprintRequest } from '@osf/features/preprints/models'; +import { + PreprintSelectors, + SubmitRequestsDecision, + SubmitReviewsDecision, +} from '@osf/features/preprints/store/preprint'; +import { StringOrNull } from '@shared/helpers'; + +@Component({ + selector: 'osf-make-decision', + imports: [Button, TranslatePipe, TitleCasePipe, Dialog, Tooltip, RadioButton, FormsModule, Textarea, Message], + templateUrl: './make-decision.component.html', + styleUrl: './make-decision.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MakeDecisionComponent { + private readonly translateService = inject(TranslateService); + private readonly router = inject(Router); + private readonly actions = createDispatchMap({ + submitReviewsDecision: SubmitReviewsDecision, + submitRequestsDecision: SubmitRequestsDecision, + }); + + protected readonly ReviewsState = ReviewsState; + + preprint = select(PreprintSelectors.getPreprint); + provider = input.required(); + latestAction = input.required(); + latestWithdrawalRequest = input.required(); + isPendingWithdrawal = input.required(); + + dialogVisible = false; + didValidate = signal(false); + decision = signal(ReviewsState.Accepted); + initialReviewerComment = signal(null); + reviewerComment = signal(null); + requestDecisionJustification = signal(null); + saving = signal(false); + + labelDecisionButton = computed(() => { + const preprint = this.preprint()!; + if (preprint.reviewsState === ReviewsState.Withdrawn) { + return 'preprints.details.decision.withdrawalReason'; + } else if (this.isPendingWithdrawal()) { + return 'preprints.details.decision.makeDecision'; + } else { + return preprint.reviewsState === ReviewsState.Pending + ? 'preprints.details.decision.makeDecision' + : 'preprints.details.decision.modifyDecision'; + } + }); + + makeDecisionButtonDisabled = computed(() => { + const reason = this.latestAction()?.comment; + const state = this.preprint()?.reviewsState; + return state === ReviewsState.Withdrawn && !reason; + }); + + labelDecisionDialogHeader = computed(() => { + const preprint = this.preprint()!; + + if (preprint.reviewsState === ReviewsState.Withdrawn) { + return 'preprints.details.decision.header.withdrawalReason'; + } else if (this.isPendingWithdrawal()) { + return 'preprints.details.decision.header.submitDecision'; + } else { + return preprint.reviewsState === ReviewsState.Pending + ? 'preprints.details.decision.header.submitDecision' + : 'preprints.details.decision.header.modifyDecision'; + } + }); + + labelSubmitButton = computed(() => { + if (this.isPendingWithdrawal()) { + return 'preprints.details.decision.submitButton.submitDecision'; + } else if (this.preprint()?.reviewsState === ReviewsState.Pending) { + return 'preprints.details.decision.submitButton.submitDecision'; + } else if (this.decisionChanged()) { + return 'preprints.details.decision.submitButton.modifyDecision'; + } else if (this.commentEdited()) { + return 'preprints.details.decision.submitButton.updateComment'; + } + return 'preprints.details.decision.submitButton.modifyDecision'; + }); + + submitButtonDisabled = computed(() => { + return (!this.decisionChanged() && !this.commentEdited()) || this.commentExceedsLimit(); + }); + + acceptOptionExplanation = computed(() => { + const reviewsWorkflow = this.provider().reviewsWorkflow; + if (reviewsWorkflow === ProviderReviewsWorkflow.PreModeration) { + return 'preprints.details.decision.accept.pre'; + } else if (reviewsWorkflow === ProviderReviewsWorkflow.PostModeration) { + return 'preprints.details.decision.accept.post'; + } + + return 'preprints.details.decision.accept.pre'; + }); + + rejectOptionLabel = computed(() => { + return this.preprint()?.isPublished + ? 'preprints.details.decision.withdrawn.label' + : 'preprints.details.decision.reject.label'; + }); + + labelRequestDecisionJustification = computed(() => { + if (this.decision() === ReviewsState.Accepted) { + return 'preprints.details.decision.withdrawalJustification'; + } else if (this.decision() === ReviewsState.Rejected) { + return 'preprints.details.decision.denialJustification'; + } + + return 'preprints.details.decision.withdrawalJustification'; + }); + + rejectOptionExplanation = computed(() => { + const reviewsWorkflow = this.provider().reviewsWorkflow; + if (reviewsWorkflow === ProviderReviewsWorkflow.PreModeration) { + if (this.preprint()?.reviewsState === ReviewsState.Accepted) { + return 'preprints.details.decision.approve.explanation'; + } else { + return decisionExplanation.reject[reviewsWorkflow]; + } + } else { + return decisionExplanation.withdrawn[reviewsWorkflow!]; + } + }); + + rejectRadioButtonValue = computed(() => { + return this.preprint()?.isPublished ? ReviewsState.Withdrawn : ReviewsState.Rejected; + }); + + settingsComments = computed(() => { + const commentType = this.provider().reviewsCommentsPrivate ? 'private' : 'public'; + return decisionSettings.comments[commentType]; + }); + + settingsNames = computed(() => { + const commentType = this.provider().reviewsCommentsAnonymous ? 'anonymous' : 'named'; + return decisionSettings.names[commentType]; + }); + + settingsModeration = computed(() => { + return decisionSettings.moderation[this.provider().reviewsWorkflow || ProviderReviewsWorkflow.PreModeration]; + }); + + commentEdited = computed(() => { + return this.reviewerComment()?.trim() !== this.initialReviewerComment(); + }); + + commentExceedsLimit = computed(() => { + const comment = this.reviewerComment(); + if (!comment) return false; + + return comment.length > formInputLimits.decisionComment.maxLength; + }); + + commentLengthErrorMessage = computed(() => { + const limit = formInputLimits.decisionComment.maxLength; + return this.translateService.instant('preprints.details.decision.commentLengthError', { + limit, + difference: Math.abs(limit - this.reviewerComment()!.length).toString(), + }); + }); + + requestDecisionJustificationErrorMessage = computed(() => { + const justification = this.requestDecisionJustification(); + const minLength = formInputLimits.requestDecisionJustification.minLength; + + if (!justification) return this.translateService.instant('preprints.details.decision.justificationRequiredError'); + if (justification.length < minLength) + return this.translateService.instant('preprints.details.decision.justificationLengthError', { + minLength, + }); + + return null; + }); + + decisionChanged = computed(() => { + return this.preprint()?.reviewsState !== this.decision(); + }); + + constructor() { + effect(() => { + const preprint = this.preprint(); + const latestAction = this.latestAction(); + if (preprint && latestAction) { + if (preprint.reviewsState === ReviewsState.Pending) { + this.decision.set(ReviewsState.Accepted); + this.initialReviewerComment.set(null); + this.reviewerComment.set(null); + } else { + this.decision.set(preprint.reviewsState); + this.initialReviewerComment.set(latestAction?.comment); + this.reviewerComment.set(latestAction?.comment); + } + } + }); + + effect(() => { + const withdrawalRequest = this.latestWithdrawalRequest(); + if (!withdrawalRequest) return; + + this.requestDecisionJustification.set(withdrawalRequest.comment); + }); + } + + submit() { + // don't remove comments + const preprint = this.preprint()!; + let trigger = ''; + if (preprint.reviewsState !== ReviewsState.Pending && this.commentEdited() && !this.decisionChanged()) { + // If the submission is not pending, + // the decision has not changed and the comment is edited. + // the trigger would be 'edit_comment' + trigger = 'edit_comment'; + } else { + let actionType = ''; + if (preprint.isPublished && this.isPendingWithdrawal()) { + // if the submission is published and is pending withdrawal. + // actionType would be 'reject' + // meaning moderators could accept/reject the withdrawl request + actionType = 'reject'; + } else if (preprint.isPublished && !this.isPendingWithdrawal()) { + // if the submission is published and is not pending withdrawal + // actionType would be 'withdraw' + // meaning moderators could approve/directly withdraw the submission + actionType = 'withdraw'; + } else { + // Otherwise + // actionType would be 'reject' + // meaning the moderator could either accept or reject the submission + actionType = 'reject'; + } + // If the decision is to accept the submission or the withdrawal request, + // the trigger is 'accept' + // If not, then the trigger is whatever 'actionType' set above. + trigger = this.decision() === ReviewsState.Accepted ? 'accept' : actionType; + } + + let comment: StringOrNull = ''; + if (this.isPendingWithdrawal()) { + if (trigger === 'reject') { + this.didValidate.set(true); + if (this.requestDecisionJustificationErrorMessage() !== null) { + return; + } + } + + comment = this.requestDecisionJustification()?.trim() || null; + } else { + comment = this.reviewerComment()?.trim() || null; + } + + this.saving.set(true); + if (this.isPendingWithdrawal()) { + this.actions.submitRequestsDecision(this.latestWithdrawalRequest()!.id, trigger, comment).subscribe({ + next: () => { + this.saving.set(false); + this.router.navigate(['preprints', this.provider().id, 'moderation', 'withdrawals']); + }, + error: () => { + this.saving.set(false); + }, + }); + } else { + this.actions.submitReviewsDecision(trigger, comment).subscribe({ + next: () => { + this.saving.set(false); + this.router.navigate(['preprints', this.provider().id, 'moderation', 'submissions']); + }, + error: () => { + this.saving.set(false); + }, + }); + } + } + + requestDecisionToggled() { + if (!this.isPendingWithdrawal()) { + return; + } + + if (this.decision() === ReviewsState.Accepted) { + this.requestDecisionJustification.set(this.latestWithdrawalRequest()?.comment || null); + } else if (this.decision() === ReviewsState.Rejected) { + this.requestDecisionJustification.set(null); + } + } + + cancel() { + this.dialogVisible = false; + this.decision.set(this.preprint()!.reviewsState); + this.reviewerComment.set(this.initialReviewerComment()); + } +} diff --git a/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.html b/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.html new file mode 100644 index 000000000..7c74d37e4 --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.html @@ -0,0 +1,31 @@ + + + + + diff --git a/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.scss b/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.spec.ts b/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.spec.ts new file mode 100644 index 000000000..5ce1214d5 --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ModerationStatusBannerComponent } from './moderation-status-banner.component'; + +describe.skip('ModerationStatusBannerComponent', () => { + let component: ModerationStatusBannerComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ModerationStatusBannerComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ModerationStatusBannerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.ts b/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.ts new file mode 100644 index 000000000..129bce73b --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.ts @@ -0,0 +1,125 @@ +import { select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Message } from 'primeng/message'; + +import { DatePipe, TitleCasePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; + +import { ReviewAction } from '@osf/features/moderation/models'; +import { + recentActivityMessageByState, + statusIconByState, + statusLabelKeyByState, + statusSeverityByState, + statusSeverityByWorkflow, +} from '@osf/features/preprints/constants'; +import { ProviderReviewsWorkflow, ReviewsState } from '@osf/features/preprints/enums'; +import { getPreprintDocumentType } from '@osf/features/preprints/helpers'; +import { PreprintProviderDetails, PreprintRequest } from '@osf/features/preprints/models'; +import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; +import { IconComponent } from '@shared/components'; + +import { environment } from 'src/environments/environment'; + +@Component({ + selector: 'osf-moderation-status-banner', + imports: [IconComponent, Message, TitleCasePipe, TranslatePipe, DatePipe], + templateUrl: './moderation-status-banner.component.html', + styleUrl: './moderation-status-banner.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ModerationStatusBannerComponent { + private readonly translateService = inject(TranslateService); + protected readonly environment = environment; + + preprint = select(PreprintSelectors.getPreprint); + provider = input.required(); + latestAction = input.required(); + latestWithdrawalRequest = input.required(); + + isPendingWithdrawal = input.required(); + noActions = computed(() => { + return this.latestAction() === null; + }); + + documentType = computed(() => { + const provider = this.provider(); + if (!provider) return null; + + return getPreprintDocumentType(provider, this.translateService); + }); + + labelDate = computed(() => { + const preprint = this.preprint()!; + return preprint.dateWithdrawn ? preprint.dateWithdrawn : preprint.dateLastTransitioned; + }); + + status = computed(() => { + const currentState = this.preprint()!.reviewsState; + + if (this.isPendingWithdrawal()) { + return statusLabelKeyByState[ReviewsState.Pending]!; + } else { + return statusLabelKeyByState[currentState]!; + } + }); + + iconClass = computed(() => { + const currentState = this.preprint()!.reviewsState; + + if (this.isPendingWithdrawal()) { + return statusIconByState[ReviewsState.Pending]; + } + + return statusIconByState[currentState]; + }); + + severity = computed(() => { + const currentState = this.preprint()!.reviewsState; + + if (this.isPendingWithdrawal()) { + return statusSeverityByState[ReviewsState.Pending]; + } else { + return currentState === ReviewsState.Pending + ? statusSeverityByWorkflow[this.provider()?.reviewsWorkflow as ProviderReviewsWorkflow] + : statusSeverityByState[currentState]; + } + }); + + recentActivityLanguage = computed(() => { + const currentState = this.preprint()!.reviewsState; + + if (this.noActions()) { + return recentActivityMessageByState.automatic[currentState]!; + } else { + return recentActivityMessageByState[currentState]!; + } + }); + + requestActivityLanguage = computed(() => { + if (!this.isPendingWithdrawal()) { + return; + } + + return recentActivityMessageByState[ReviewsState.PendingWithdrawal]; + }); + + actionCreatorName = computed(() => { + return this.latestAction()?.creator.name; + }); + actionCreatorLink = computed(() => { + return `${environment.webUrl}/${this.actionCreatorId()}`; + }); + actionCreatorId = computed(() => { + return this.latestAction()?.creator.id; + }); + + withdrawalRequesterName = computed(() => { + return this.latestWithdrawalRequest()?.creator.name; + }); + withdrawalRequesterId = computed(() => { + return this.latestWithdrawalRequest()?.creator.id; + }); +} diff --git a/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.ts b/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.ts index 43f4e9110..869822f1f 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.ts @@ -1,16 +1,14 @@ -import { createDispatchMap, select } from '@ngxs/store'; +import { select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Select } from 'primeng/select'; -import { Location } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; import { PreprintProviderDetails } from '@osf/features/preprints/models'; -import { FetchPreprintById, PreprintSelectors } from '@osf/features/preprints/store/preprint'; +import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; @Component({ selector: 'osf-preprint-doi-section', @@ -20,16 +18,11 @@ import { FetchPreprintById, PreprintSelectors } from '@osf/features/preprints/st changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintDoiSectionComponent { - private readonly router = inject(Router); - private readonly location = inject(Location); - - private actions = createDispatchMap({ - fetchPreprintById: FetchPreprintById, - }); - preprintProvider = input.required(); preprint = select(PreprintSelectors.getPreprint); + preprintVersionSelected = output(); + preprintVersionIds = select(PreprintSelectors.getPreprintVersionIds); arePreprintVersionIdsLoading = select(PreprintSelectors.arePreprintVersionIdsLoading); @@ -46,13 +39,6 @@ export class PreprintDoiSectionComponent { selectPreprintVersion(versionId: string) { if (this.preprint()!.id === versionId) return; - this.actions.fetchPreprintById(versionId).subscribe({ - complete: () => { - const currentUrl = this.router.url; - const newUrl = currentUrl.replace(/[^/]+$/, versionId); - - this.location.replaceState(newUrl); - }, - }); + this.preprintVersionSelected.emit(versionId); } } diff --git a/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.html b/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.html index 84d2a0efa..56b0e8d3a 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.html +++ b/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.html @@ -2,6 +2,13 @@ @if (preprint()) { @let preprintValue = preprint()!;
+ @if (preprintValue.withdrawalJustification) { +
+

{{ 'preprints.details.reasonForWithdrawal' | translate }}

+

{{ preprintValue.withdrawalJustification }}

+
+ } +

{{ 'preprints.preprintStepper.review.sections.metadata.authors' | translate }}

@@ -24,7 +31,10 @@

{{ 'preprints.preprintStepper.common.labels.abstract' | translate }}

- +

{{ 'preprints.preprintStepper.review.sections.metadata.license' | translate }}

diff --git a/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.ts b/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.ts index 30ab75ebd..a41200bee 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.ts @@ -8,7 +8,7 @@ import { Skeleton } from 'primeng/skeleton'; import { Tag } from 'primeng/tag'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, effect, input, OnDestroy } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, input, OnDestroy, output } from '@angular/core'; import { PreprintDoiSectionComponent } from '@osf/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component'; import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums'; @@ -55,6 +55,8 @@ export class PreprintTombstoneComponent implements OnDestroy { fetchPreprintById: FetchPreprintById, fetchSubjects: FetchSelectedSubjects, }); + preprintVersionSelected = output(); + preprintProvider = input.required(); preprint = select(PreprintSelectors.getPreprint); diff --git a/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.ts b/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.ts index c60c0db96..cb6ddfde5 100644 --- a/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.ts +++ b/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.ts @@ -20,7 +20,7 @@ import { statusSeverityByWorkflow, } from '@osf/features/preprints/constants'; import { ProviderReviewsWorkflow, ReviewsState } from '@osf/features/preprints/enums'; -import { PreprintProviderDetails } from '@osf/features/preprints/models'; +import { PreprintProviderDetails, PreprintRequestAction } from '@osf/features/preprints/models'; import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { IconComponent } from '@shared/components'; @@ -39,6 +39,7 @@ export class StatusBannerComponent { latestAction = input.required(); isPendingWithdrawal = input.required(); isWithdrawalRejected = input.required(); + latestRequestAction = input.required(); feedbackDialogVisible = false; @@ -83,11 +84,19 @@ export class StatusBannerComponent { }); reviewerName = computed(() => { - return this.latestAction()?.creator.name; + if (this.isWithdrawalRejected()) { + return this.latestRequestAction()?.creator.name; + } else { + return this.latestAction()?.creator.name; + } }); reviewerComment = computed(() => { - return this.latestAction()?.comment; + if (this.isWithdrawalRejected()) { + return this.latestRequestAction()?.comment; + } else { + return this.latestAction()?.comment; + } }); isWithdrawn = computed(() => { diff --git a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.html b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.html index a6cee4c1a..84f8d707e 100644 --- a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.html +++ b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.html @@ -58,12 +58,13 @@

{{ preprintProvider()!.name }}

} - @if (isPreprintProviderLoading()) { - } @else { + } @else if (preprintProvider()?.examplePreprintId) {

- {{ 'preprints.showExample' | translate }} + {{ 'preprints.showExample' | translate }} +

} diff --git a/src/app/features/preprints/components/stepper/review-step/review-step.component.ts b/src/app/features/preprints/components/stepper/review-step/review-step.component.ts index ee80a5345..e4a131298 100644 --- a/src/app/features/preprints/components/stepper/review-step/review-step.component.ts +++ b/src/app/features/preprints/components/stepper/review-step/review-step.component.ts @@ -11,7 +11,7 @@ import { DatePipe, TitleCasePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, input, OnInit, signal } from '@angular/core'; import { Router } from '@angular/router'; -import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums'; +import { ApplicabilityStatus, PreregLinkInfo, ReviewsState } from '@osf/features/preprints/enums'; import { PreprintProviderDetails } from '@osf/features/preprints/models'; import { FetchLicenses, @@ -83,12 +83,16 @@ export class ReviewStepComponent implements OnInit { } submitPreprint() { - this.actions.submitPreprint().subscribe({ - complete: () => { - this.toastService.showSuccess('preprints.preprintStepper.common.successMessages.preprintSubmitted'); - this.router.navigate(['/preprints', this.provider()!.id, this.preprint()!.id]); - }, - }); + if (this.preprint()?.reviewsState !== ReviewsState.Accepted) { + this.actions.submitPreprint().subscribe({ + complete: () => { + this.toastService.showSuccess('preprints.preprintStepper.common.successMessages.preprintSubmitted'); + this.router.navigate(['/preprints', this.provider()!.id, this.preprint()!.id]); + }, + }); + } else { + this.toastService.showSuccess('preprints.preprintStepper.common.successMessages.preprintSubmitted'); + } } cancelSubmission() { diff --git a/src/app/features/preprints/constants/form-input-limits.const.ts b/src/app/features/preprints/constants/form-input-limits.const.ts index ab0596bb2..2fb0de2d2 100644 --- a/src/app/features/preprints/constants/form-input-limits.const.ts +++ b/src/app/features/preprints/constants/form-input-limits.const.ts @@ -15,4 +15,10 @@ export const formInputLimits = { withdrawalJustification: { minLength: 25, }, + decisionComment: { + maxLength: 100, + }, + requestDecisionJustification: { + minLength: 20, + }, }; diff --git a/src/app/features/preprints/constants/index.ts b/src/app/features/preprints/constants/index.ts index fb8a0f186..936d1f7f4 100644 --- a/src/app/features/preprints/constants/index.ts +++ b/src/app/features/preprints/constants/index.ts @@ -1,5 +1,6 @@ export * from './create-new-version-steps.const'; export * from './form-input-limits.const'; +export * from './make-decision.const'; export * from './preprints-fields.const'; export * from './prereg-link-options.const'; export * from './status-banner.const'; diff --git a/src/app/features/preprints/constants/make-decision.const.ts b/src/app/features/preprints/constants/make-decision.const.ts new file mode 100644 index 000000000..04f1d3377 --- /dev/null +++ b/src/app/features/preprints/constants/make-decision.const.ts @@ -0,0 +1,30 @@ +import { ProviderReviewsWorkflow } from '@osf/features/preprints/enums'; + +export const decisionSettings = { + comments: { + public: 'preprints.details.decision.settings.comments.public', + private: 'preprints.details.decision.settings.comments.private', + }, + names: { + anonymous: 'preprints.details.decision.settings.names.anonymous', + named: 'preprints.details.decision.settings.names.named', + }, + moderation: { + [ProviderReviewsWorkflow.PreModeration]: 'preprints.details.decision.settings.moderation.pre', + [ProviderReviewsWorkflow.PostModeration]: 'preprints.details.decision.settings.moderation.post', + }, +}; + +export const decisionExplanation = { + accept: { + [ProviderReviewsWorkflow.PreModeration]: 'preprints.details.decision.accept.pre', + [ProviderReviewsWorkflow.PostModeration]: 'preprints.details.decision.accept.post', + }, + reject: { + [ProviderReviewsWorkflow.PreModeration]: 'preprints.details.decision.reject.pre', + [ProviderReviewsWorkflow.PostModeration]: 'preprints.details.decision.reject.post', + }, + withdrawn: { + [ProviderReviewsWorkflow.PostModeration]: 'preprints.details.decision.withdrawn.post', + }, +}; diff --git a/src/app/features/preprints/constants/status-banner.const.ts b/src/app/features/preprints/constants/status-banner.const.ts index 61da85340..fa87c962d 100644 --- a/src/app/features/preprints/constants/status-banner.const.ts +++ b/src/app/features/preprints/constants/status-banner.const.ts @@ -17,7 +17,7 @@ export const statusIconByState: Partial> = { [ReviewsState.Rejected]: 'times-circle', [ReviewsState.PendingWithdrawal]: 'hourglass', [ReviewsState.WithdrawalRejected]: 'times-circle', - [ReviewsState.Withdrawn]: 'exclamation-triangle', + [ReviewsState.Withdrawn]: 'circle-minus', }; export const statusMessageByWorkflow: Record = { @@ -44,4 +44,21 @@ export const statusSeverityByState: Partial [ReviewsState.PendingWithdrawal]: 'error', [ReviewsState.WithdrawalRejected]: 'error', [ReviewsState.Withdrawn]: 'warn', + [ReviewsState.Pending]: 'warn', +}; + +type ActivityMap = Partial>; + +export const recentActivityMessageByState: ActivityMap & { + automatic: ActivityMap; +} = { + [ReviewsState.Pending]: 'preprints.details.moderationStatusBanner.recentActivity.pending', + [ReviewsState.Accepted]: 'preprints.details.moderationStatusBanner.recentActivity.accepted', + [ReviewsState.Rejected]: 'preprints.details.moderationStatusBanner.recentActivity.rejected', + [ReviewsState.PendingWithdrawal]: 'preprints.details.moderationStatusBanner.recentActivity.pendingWithdrawal', + [ReviewsState.Withdrawn]: 'preprints.details.moderationStatusBanner.recentActivity.withdrawn', + automatic: { + [ReviewsState.Pending]: 'preprints.details.moderationStatusBanner.recentActivity.automatic.pending', + [ReviewsState.Accepted]: 'preprints.details.moderationStatusBanner.recentActivity.automatic.accepted', + }, }; diff --git a/src/app/features/preprints/mappers/index.ts b/src/app/features/preprints/mappers/index.ts index c9fbba01e..18f9a8804 100644 --- a/src/app/features/preprints/mappers/index.ts +++ b/src/app/features/preprints/mappers/index.ts @@ -1,3 +1,4 @@ export * from './preprint-providers.mapper'; export * from './preprint-request.mapper'; +export * from './preprint-request-actions.mapper'; export * from './preprints.mapper'; diff --git a/src/app/features/preprints/mappers/preprint-providers.mapper.ts b/src/app/features/preprints/mappers/preprint-providers.mapper.ts index b9fecd7fe..4f74aeae4 100644 --- a/src/app/features/preprints/mappers/preprint-providers.mapper.ts +++ b/src/app/features/preprints/mappers/preprint-providers.mapper.ts @@ -19,6 +19,7 @@ export class PreprintProvidersMapper { preprintWord: response.attributes.preprint_word, allowSubmissions: response.attributes.allow_submissions, assertionsEnabled: response.attributes.assertions_enabled, + permissions: response.attributes.permissions, brand: { id: brandRaw.id, name: brandRaw.attributes.name, diff --git a/src/app/features/preprints/mappers/preprint-request-actions.mapper.ts b/src/app/features/preprints/mappers/preprint-request-actions.mapper.ts new file mode 100644 index 000000000..62bbc1477 --- /dev/null +++ b/src/app/features/preprints/mappers/preprint-request-actions.mapper.ts @@ -0,0 +1,39 @@ +import { PreprintRequestAction, PreprintRequestActionDataJsonApi } from '@osf/features/preprints/models'; +import { StringOrNull } from '@shared/helpers'; + +export class PreprintRequestActionsMapper { + static fromPreprintRequestActions(data: PreprintRequestActionDataJsonApi): PreprintRequestAction { + return { + id: data.id, + trigger: data.attributes.trigger, + comment: data.attributes.comment, + fromState: data.attributes.from_state, + toState: data.attributes.to_state, + dateModified: data.attributes.date_modified, + creator: { + id: data.embeds.creator.data.id, + name: data.embeds.creator.data.attributes.full_name, + }, + }; + } + + static toRequestActionPayload(requestId: string, trigger: string, comment: StringOrNull) { + return { + data: { + type: 'preprint_request_actions', + attributes: { + trigger, + ...(comment && { comment }), + }, + relationships: { + target: { + data: { + type: 'preprint-requests', + id: requestId, + }, + }, + }, + }, + }; + } +} diff --git a/src/app/features/preprints/mappers/preprint-request.mapper.ts b/src/app/features/preprints/mappers/preprint-request.mapper.ts index cee3612b1..48877ae38 100644 --- a/src/app/features/preprints/mappers/preprint-request.mapper.ts +++ b/src/app/features/preprints/mappers/preprint-request.mapper.ts @@ -28,6 +28,11 @@ export class PreprintRequestMapper { comment: data.attributes.comment, requestType: data.attributes.request_type, machineState: data.attributes.machine_state, + dateLastTransitioned: data.attributes.date_last_transitioned, + creator: { + id: data.embeds.creator.data.id, + name: data.embeds.creator.data.attributes.full_name, + }, }; } } diff --git a/src/app/features/preprints/mappers/preprints.mapper.ts b/src/app/features/preprints/mappers/preprints.mapper.ts index f182b6f3c..317d881ea 100644 --- a/src/app/features/preprints/mappers/preprints.mapper.ts +++ b/src/app/features/preprints/mappers/preprints.mapper.ts @@ -1,5 +1,6 @@ import { LicensesMapper } from '@osf/shared/mappers'; import { ApiData, JsonApiResponseWithMeta, ResponseJsonApi } from '@osf/shared/models'; +import { StringOrNull } from '@shared/helpers'; import { Preprint, @@ -41,6 +42,7 @@ export class PreprintsMapper { dateModified: response.attributes.date_modified, dateWithdrawn: response.attributes.date_withdrawn, datePublished: response.attributes.date_published, + dateLastTransitioned: response.attributes.date_last_transitioned, title: response.attributes.title, description: response.attributes.description, reviewsState: response.attributes.reviews_state, @@ -51,6 +53,7 @@ export class PreprintsMapper { originalPublicationDate: response.attributes.original_publication_date, isPublished: response.attributes.is_published, tags: response.attributes.tags, + withdrawalJustification: response.attributes.withdrawal_justification, isPublic: response.attributes.public, version: response.attributes.version, isLatestVersion: response.attributes.is_latest_version, @@ -94,6 +97,7 @@ export class PreprintsMapper { dateModified: data.attributes.date_modified, dateWithdrawn: data.attributes.date_withdrawn, datePublished: data.attributes.date_published, + dateLastTransitioned: data.attributes.date_last_transitioned, title: data.attributes.title, description: data.attributes.description, reviewsState: data.attributes.reviews_state, @@ -103,6 +107,7 @@ export class PreprintsMapper { customPublicationCitation: data.attributes.custom_publication_citation, originalPublicationDate: data.attributes.original_publication_date, isPublished: data.attributes.is_published, + withdrawalJustification: data.attributes.withdrawal_justification, tags: data.attributes.tags, isPublic: data.attributes.public, version: data.attributes.version, @@ -136,12 +141,13 @@ export class PreprintsMapper { }; } - static toSubmitPreprintPayload(preprintId: string) { + static toReviewActionPayload(preprintId: string, trigger: string, comment?: StringOrNull) { return { data: { type: 'review_actions', attributes: { - trigger: 'submit', + trigger, + ...(comment && { comment }), }, relationships: { target: { diff --git a/src/app/features/preprints/models/index.ts b/src/app/features/preprints/models/index.ts index 3cc0d9423..9131b75cb 100644 --- a/src/app/features/preprints/models/index.ts +++ b/src/app/features/preprints/models/index.ts @@ -4,5 +4,7 @@ export * from './preprint-licenses-json-api.models'; export * from './preprint-provider.models'; export * from './preprint-provider-json-api.models'; export * from './preprint-request.models'; +export * from './preprint-request-action.models'; +export * from './preprint-request-action-json-api.models'; export * from './preprint-request-json-api.models'; export * from './submit-preprint-form.models'; diff --git a/src/app/features/preprints/models/preprint-json-api.models.ts b/src/app/features/preprints/models/preprint-json-api.models.ts index 936595b4c..858555abf 100644 --- a/src/app/features/preprints/models/preprint-json-api.models.ts +++ b/src/app/features/preprints/models/preprint-json-api.models.ts @@ -9,6 +9,9 @@ export interface PreprintAttributesJsonApi { date_modified: string; date_published: Date | null; original_publication_date: Date | null; + date_last_transitioned: Date | null; + date_withdrawn: Date | null; + withdrawal_justification: StringOrNull; custom_publication_citation: StringOrNull; doi: StringOrNull; preprint_doi_created: Date | null; @@ -18,11 +21,9 @@ export interface PreprintAttributesJsonApi { is_preprint_orphan: boolean; license_record: LicenseRecordJsonApi | null; tags: string[]; - date_withdrawn: Date | null; current_user_permissions: UserPermissions[]; public: boolean; reviews_state: ReviewsState; - date_last_transitioned: Date | null; version: number; is_latest_version: boolean; has_coi: BooleanOrNull; diff --git a/src/app/features/preprints/models/preprint-provider-json-api.models.ts b/src/app/features/preprints/models/preprint-provider-json-api.models.ts index fa568105b..7e92396c2 100644 --- a/src/app/features/preprints/models/preprint-provider-json-api.models.ts +++ b/src/app/features/preprints/models/preprint-provider-json-api.models.ts @@ -1,5 +1,6 @@ import { StringOrNull } from '@osf/shared/helpers'; -import { BrandDataJsonApi } from '@osf/shared/models'; +import { ReviewPermissions } from '@shared/enums/review-permissions.enum'; +import { BrandDataJsonApi } from '@shared/models'; import { ProviderReviewsWorkflow } from '../enums'; @@ -16,6 +17,7 @@ export interface PreprintProviderDetailsJsonApi { domain: string; footer_links: string; preprint_word: PreprintWord; + permissions: ReviewPermissions[]; assets: { wide_white: string; square_color_no_transparent: string; diff --git a/src/app/features/preprints/models/preprint-provider.models.ts b/src/app/features/preprints/models/preprint-provider.models.ts index 0fbc718b5..bc60444a9 100644 --- a/src/app/features/preprints/models/preprint-provider.models.ts +++ b/src/app/features/preprints/models/preprint-provider.models.ts @@ -1,5 +1,6 @@ -import { StringOrNull } from '@osf/shared/helpers'; -import { Brand } from '@osf/shared/models'; +import { ReviewPermissions } from '@shared/enums/review-permissions.enum'; +import { StringOrNull } from '@shared/helpers'; +import { Brand } from '@shared/models'; import { ProviderReviewsWorkflow } from '../enums'; @@ -18,6 +19,7 @@ export interface PreprintProviderDetails { allowSubmissions: boolean; assertionsEnabled: boolean; reviewsWorkflow: ProviderReviewsWorkflow | null; + permissions: ReviewPermissions[]; brand: Brand; lastFetched?: number; iri: string; diff --git a/src/app/features/preprints/models/preprint-request-action-json-api.models.ts b/src/app/features/preprints/models/preprint-request-action-json-api.models.ts new file mode 100644 index 000000000..311375c6a --- /dev/null +++ b/src/app/features/preprints/models/preprint-request-action-json-api.models.ts @@ -0,0 +1,35 @@ +import { JsonApiResponse } from '@osf/shared/models'; + +export type PreprintRequestActionsJsonApiResponse = JsonApiResponse; + +export interface PreprintRequestActionDataJsonApi { + id: string; + type: 'preprint_request_actions'; + attributes: PreprintRequestActionsAttributesJsonApi; + embeds: PreprintRequestEmbedsJsonApi; +} + +interface PreprintRequestActionsAttributesJsonApi { + trigger: string; + comment: string; + from_state: string; + to_state: string; + date_created: Date; + date_modified: Date; +} + +interface PreprintRequestEmbedsJsonApi { + creator: { + data: UserModelJsonApi; + }; +} + +interface UserModelJsonApi { + id: string; + type: 'users'; + attributes: UserAttributesJsonApi; +} + +interface UserAttributesJsonApi { + full_name: string; +} diff --git a/src/app/features/preprints/models/preprint-request-action.models.ts b/src/app/features/preprints/models/preprint-request-action.models.ts new file mode 100644 index 000000000..7fbdf8fee --- /dev/null +++ b/src/app/features/preprints/models/preprint-request-action.models.ts @@ -0,0 +1,11 @@ +import { IdName } from '@shared/models'; + +export interface PreprintRequestAction { + id: string; + trigger: string; + comment: string; + fromState: string; + toState: string; + dateModified: Date; + creator: IdName; +} diff --git a/src/app/features/preprints/models/preprint-request-json-api.models.ts b/src/app/features/preprints/models/preprint-request-json-api.models.ts index 4a98f3cfc..ae8ad5d30 100644 --- a/src/app/features/preprints/models/preprint-request-json-api.models.ts +++ b/src/app/features/preprints/models/preprint-request-json-api.models.ts @@ -7,6 +7,7 @@ export interface PreprintRequestDataJsonApi { id: string; type: 'preprint_requests'; attributes: PreprintRequestAttributesJsonApi; + embeds: PreprintRequestEmbedsJsonApi; } interface PreprintRequestAttributesJsonApi { @@ -17,3 +18,19 @@ interface PreprintRequestAttributesJsonApi { modified: Date; date_last_transitioned: Date; } + +interface PreprintRequestEmbedsJsonApi { + creator: { + data: UserModelJsonApi; + }; +} + +interface UserModelJsonApi { + id: string; + type: 'users'; + attributes: UserAttributesJsonApi; +} + +interface UserAttributesJsonApi { + full_name: string; +} diff --git a/src/app/features/preprints/models/preprint-request.models.ts b/src/app/features/preprints/models/preprint-request.models.ts index d7231abe8..9489fbaa4 100644 --- a/src/app/features/preprints/models/preprint-request.models.ts +++ b/src/app/features/preprints/models/preprint-request.models.ts @@ -1,8 +1,11 @@ import { PreprintRequestMachineState, PreprintRequestType } from '@osf/features/preprints/enums'; +import { IdName } from '@shared/models'; export interface PreprintRequest { id: string; comment: string; machineState: PreprintRequestMachineState; requestType: PreprintRequestType; + dateLastTransitioned: Date; + creator: IdName; } diff --git a/src/app/features/preprints/models/preprint.models.ts b/src/app/features/preprints/models/preprint.models.ts index 8f2471609..11a7283bd 100644 --- a/src/app/features/preprints/models/preprint.models.ts +++ b/src/app/features/preprints/models/preprint.models.ts @@ -10,6 +10,7 @@ export interface Preprint { dateModified: string; dateWithdrawn: Date | null; datePublished: Date | null; + dateLastTransitioned: Date | null; title: string; description: string; reviewsState: ReviewsState; @@ -24,6 +25,7 @@ export interface Preprint { version: number; isLatestVersion: boolean; isPreprintOrphan: boolean; + withdrawalJustification: StringOrNull; nodeId: StringOrNull; primaryFileId: StringOrNull; licenseId: StringOrNull; diff --git a/src/app/features/preprints/pages/landing/preprints-landing.component.html b/src/app/features/preprints/pages/landing/preprints-landing.component.html index a3ddcf395..13845ba84 100644 --- a/src/app/features/preprints/pages/landing/preprints-landing.component.html +++ b/src/app/features/preprints/pages/landing/preprints-landing.component.html @@ -35,11 +35,14 @@

{{ 'preprints.title' | translate }}

(triggerSearch)="redirectToSearchPageWithValue()" /> - @if (isPreprintProviderLoading()) { - } @else { - {{ 'preprints.showExample' | translate }} + } @else if (osfPreprintProvider()!.examplePreprintId) { + {{ 'preprints.showExample' | translate }} + } diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.html b/src/app/features/preprints/pages/preprint-details/preprint-details.component.html index b135a1a31..56f3e4b58 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.html +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.html @@ -1,12 +1,34 @@
-
+
@if (isPreprintProviderLoading() || isPreprintLoading()) { - - +
+ + +
} @else { - Provider Logo -

{{ preprint()!.title }}

+
+ Provider Logo +

{{ preprint()!.title }}

+
+ } + + @if (moderationMode()) { + @if ( + isPreprintLoading() || + areReviewActionsLoading() || + areWithdrawalRequestsLoading() || + areRequestActionsLoading() + ) { + + } @else { + + } }
@@ -46,10 +68,19 @@

{{ preprint()!.title }}

+ @if (moderationStatusBannerVisible()) { + + } @if (statusBannerVisible()) { @@ -63,12 +94,19 @@

{{ preprint()!.title }}

- +
} @else { - + }
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 c37a570d6..51744791c 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 @@ -8,7 +8,7 @@ import { Skeleton } from 'primeng/skeleton'; import { filter, map, of } from 'rxjs'; -import { DatePipe } from '@angular/common'; +import { DatePipe, Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -26,28 +26,29 @@ import { UserSelectors } from '@core/store/user'; import { AdditionalInfoComponent, GeneralInformationComponent, + MakeDecisionComponent, + ModerationStatusBannerComponent, PreprintFileSectionComponent, + PreprintTombstoneComponent, ShareAndDownloadComponent, StatusBannerComponent, WithdrawDialogComponent, } from '@osf/features/preprints/components'; -import { UserPermissions } from '@osf/shared/enums'; -import { IS_MEDIUM, pathJoin } from '@osf/shared/helpers'; -import { ContributorModel } from '@osf/shared/models'; -import { MetaTagsService } from '@osf/shared/services'; -import { ContributorsSelectors } from '@osf/shared/stores'; - -import { PreprintTombstoneComponent } from '../../components/preprint-details/preprint-tombstone/preprint-tombstone.component'; -import { PreprintRequestMachineState, ProviderReviewsWorkflow, ReviewsState } from '../../enums'; +import { PreprintRequestMachineState, ProviderReviewsWorkflow, ReviewsState } from '@osf/features/preprints/enums'; import { FetchPreprintById, + FetchPreprintRequestActions, FetchPreprintRequests, FetchPreprintReviewActions, PreprintSelectors, ResetState, -} from '../../store/preprint'; -import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { CreateNewVersion, PreprintStepperSelectors } from '../../store/preprint-stepper'; +} from '@osf/features/preprints/store/preprint'; +import { GetPreprintProviderById, PreprintProvidersSelectors } from '@osf/features/preprints/store/preprint-providers'; +import { CreateNewVersion, PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; +import { IS_MEDIUM, pathJoin } from '@osf/shared/helpers'; +import { ReviewPermissions, UserPermissions } from '@shared/enums'; +import { MetaTagsService } from '@shared/services'; +import { ContributorsSelectors } from '@shared/stores'; import { environment } from 'src/environments/environment'; @@ -63,6 +64,8 @@ import { environment } from 'src/environments/environment'; StatusBannerComponent, TranslatePipe, PreprintTombstoneComponent, + ModerationStatusBannerComponent, + MakeDecisionComponent, ], templateUrl: './preprint-details.component.html', styleUrl: './preprint-details.component.scss', @@ -72,9 +75,10 @@ import { environment } from 'src/environments/environment'; export class PreprintDetailsComponent implements OnInit, OnDestroy { @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; + private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); + private readonly location = inject(Location); private readonly store = inject(Store); - private readonly router = inject(Router); private readonly dialogService = inject(DialogService); private readonly destroyRef = inject(DestroyRef); private readonly translateService = inject(TranslateService); @@ -92,8 +96,8 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { createNewVersion: CreateNewVersion, fetchPreprintRequests: FetchPreprintRequests, fetchPreprintReviewActions: FetchPreprintReviewActions, + fetchPreprintRequestActions: FetchPreprintRequestActions, }); - currentUser = select(UserSelectors.getCurrentUser); preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); @@ -105,6 +109,14 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { areReviewActionsLoading = select(PreprintSelectors.arePreprintReviewActionsLoading); withdrawalRequests = select(PreprintSelectors.getPreprintRequests); areWithdrawalRequestsLoading = select(PreprintSelectors.arePreprintRequestsLoading); + requestActions = select(PreprintSelectors.getPreprintRequestActions); + areRequestActionsLoading = select(PreprintSelectors.arePreprintRequestActionsLoading); + + isPresentModeratorQueryParam = toSignal(this.route.queryParams.pipe(map((params) => params['mode'] === 'moderator'))); + moderationMode = computed(() => { + const provider = this.preprintProvider(); + return this.isPresentModeratorQueryParam() && provider?.permissions.includes(ReviewPermissions.ViewSubmissions); + }); latestAction = computed(() => { const actions = this.reviewActions(); @@ -120,6 +132,13 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { return requests[0]; }); + latestRequestAction = computed(() => { + const actions = this.requestActions(); + + if (actions.length < 1) return null; + + return actions[0]; + }); private currentUserIsAdmin = computed(() => { return this.preprint()?.currentUserPermissions.includes(UserPermissions.Admin) || false; @@ -127,18 +146,15 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { private currentUserIsContributor = computed(() => { const contributors = this.contributors(); - const preprint = this.preprint()!; const currentUser = this.currentUser(); if (this.currentUserIsAdmin()) { return true; } else if (contributors.length) { - const authorIds = [] as string[]; - contributors.forEach((author: ContributorModel) => { - authorIds.push(author.id); - }); - const authorId = `${preprint.id}-${currentUser?.id}`; - return currentUser?.id ? authorIds.includes(authorId) && this.hasReadWriteAccess() : false; + const authorIds = contributors.map((author) => author.id); + return currentUser?.id + ? authorIds.some((id) => id.endsWith(currentUser!.id)) && this.hasReadWriteAccess() + : false; } return false; }); @@ -204,13 +220,13 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { }); isWithdrawalRejected = computed(() => { - //[RNi] TODO: Implement when request actions available - //const isPreprintRequestActionModel = this.args.latestAction instanceof PreprintRequestActionModel; - // return isPreprintRequestActionModel && this.args.latestAction?.actionTrigger === 'reject'; - return false; + const latestRequestActions = this.latestRequestAction(); + if (!latestRequestActions) return false; + return latestRequestActions?.trigger === 'reject'; }); withdrawalButtonVisible = computed(() => { + if (this.areWithdrawalRequestsLoading() || this.areRequestActionsLoading()) return false; return ( this.currentUserIsAdmin() && this.preprintWithdrawableState() && @@ -219,10 +235,29 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { ); }); + moderationStatusBannerVisible = computed(() => { + return ( + this.moderationMode() && + !( + this.isPreprintLoading() || + this.areReviewActionsLoading() || + this.areWithdrawalRequestsLoading() || + this.areRequestActionsLoading() + ) + ); + }); + statusBannerVisible = computed(() => { const provider = this.preprintProvider(); const preprint = this.preprint(); - if (!provider || !preprint || this.areWithdrawalRequestsLoading() || this.areReviewActionsLoading()) return false; + if ( + !provider || + !preprint || + this.areWithdrawalRequestsLoading() || + this.areReviewActionsLoading() || + this.areRequestActionsLoading() + ) + return false; return ( provider.reviewsWorkflow && @@ -234,14 +269,24 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { }); ngOnInit() { - this.fetchPreprint(); - this.actions.getPreprintProviderById(this.providerId()); + this.actions.getPreprintProviderById(this.providerId()).subscribe({ + next: () => { + this.fetchPreprint(this.preprintId()); + }, + }); } ngOnDestroy() { this.actions.resetState(); } + fetchPreprintVersion(preprintVersionId: string) { + const currentUrl = this.router.url; + const newUrl = currentUrl.replace(/[^/]+$/, preprintVersionId); + this.location.replaceState(newUrl); + this.fetchPreprint(preprintVersionId); + } + handleWithdrawClicked() { const dialogWidth = this.isMedium() ? '700px' : '340px'; @@ -262,7 +307,7 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { dialogRef.onClose.pipe(takeUntilDestroyed(this.destroyRef), filter(Boolean)).subscribe({ next: () => { - this.fetchPreprint(); + this.fetchPreprint(this.preprintId()); }, }); } @@ -280,12 +325,21 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { }); } - private fetchPreprint() { - this.actions.fetchPreprintById(this.preprintId()).subscribe({ + private fetchPreprint(preprintId: string) { + this.actions.fetchPreprintById(preprintId).subscribe({ next: () => { - if (this.preprint()!.currentUserPermissions.length > 0) { - this.actions.fetchPreprintRequests(); + if (this.preprint()!.currentUserPermissions.length > 0 || this.moderationMode()) { this.actions.fetchPreprintReviewActions(); + if (this.preprintWithdrawableState() && (this.currentUserIsAdmin() || this.moderationMode())) { + this.actions.fetchPreprintRequests().subscribe({ + next: () => { + const latestWithdrawalRequest = this.latestWithdrawalRequest(); + if (latestWithdrawalRequest) { + this.actions.fetchPreprintRequestActions(latestWithdrawalRequest.id); + } + }, + }); + } } this.setMetaTags(); diff --git a/src/app/features/preprints/services/preprints.service.ts b/src/app/features/preprints/services/preprints.service.ts index dd472fe60..d52fde721 100644 --- a/src/app/features/preprints/services/preprints.service.ts +++ b/src/app/features/preprints/services/preprints.service.ts @@ -4,7 +4,9 @@ import { inject, Injectable } from '@angular/core'; import { RegistryModerationMapper } from '@osf/features/moderation/mappers'; import { ReviewActionsResponseJsonApi } from '@osf/features/moderation/models'; -import { searchPreferencesToJsonApiQueryParams } from '@osf/shared/helpers'; +import { PreprintRequestActionsMapper } from '@osf/features/preprints/mappers/preprint-request-actions.mapper'; +import { PreprintRequestAction } from '@osf/features/preprints/models/preprint-request-action.models'; +import { searchPreferencesToJsonApiQueryParams, StringOrNull } from '@osf/shared/helpers'; import { ApiData, JsonApiResponse, JsonApiResponseWithMeta, ResponseJsonApi, SearchFilters } from '@osf/shared/models'; import { JsonApiService } from '@osf/shared/services'; @@ -18,6 +20,7 @@ import { PreprintMetaJsonApi, PreprintRelationshipsJsonApi, PreprintRequest, + PreprintRequestActionsJsonApiResponse, PreprintRequestsJsonApiResponse, } from '../models'; @@ -109,7 +112,7 @@ export class PreprintsService { } submitPreprint(preprintId: string) { - const payload = PreprintsMapper.toSubmitPreprintPayload(preprintId); + const payload = PreprintsMapper.toReviewActionPayload(preprintId, 'submit'); return this.jsonApiService.post(`${environment.apiUrl}/preprints/${preprintId}/review_actions/`, payload); } @@ -167,16 +170,34 @@ export class PreprintsService { } getPreprintRequests(preprintId: string): Observable { - const baseUrl = `${environment.apiUrl}/preprints/${preprintId}/requests/`; + const baseUrl = `${environment.apiUrl}/preprints/${preprintId}/requests/?embed=creator`; return this.jsonApiService .get(baseUrl) .pipe(map((response) => response.data.map((x) => PreprintRequestMapper.fromPreprintRequest(x)))); } + getPreprintRequestActions(requestId: string): Observable { + const baseUrl = `${environment.apiUrl}/requests/${requestId}/actions/`; + + return this.jsonApiService + .get(baseUrl) + .pipe(map((response) => response.data.map((x) => PreprintRequestActionsMapper.fromPreprintRequestActions(x)))); + } + withdrawPreprint(preprintId: string, justification: string) { const payload = PreprintRequestMapper.toWithdrawPreprintPayload(preprintId, justification); return this.jsonApiService.post(`${environment.apiUrl}/preprints/${preprintId}/requests/`, payload); } + + submitReviewsDecision(preprintId: string, trigger: string, comment: StringOrNull) { + const payload = PreprintsMapper.toReviewActionPayload(preprintId, trigger, comment); + return this.jsonApiService.post(`${environment.apiUrl}/actions/reviews/`, payload); + } + + submitRequestsDecision(requestId: string, trigger: string, comment: StringOrNull) { + const payload = PreprintRequestActionsMapper.toRequestActionPayload(requestId, trigger, comment); + return this.jsonApiService.post(`${environment.apiUrl}/actions/requests/preprints/`, payload); + } } diff --git a/src/app/features/preprints/store/preprint/preprint.actions.ts b/src/app/features/preprints/store/preprint/preprint.actions.ts index f272a5428..5446d1966 100644 --- a/src/app/features/preprints/store/preprint/preprint.actions.ts +++ b/src/app/features/preprints/store/preprint/preprint.actions.ts @@ -1,3 +1,4 @@ +import { StringOrNull } from '@shared/helpers'; import { SearchFilters } from '@shared/models'; export class FetchMyPreprints { @@ -36,6 +37,12 @@ export class FetchPreprintRequests { static readonly type = '[Preprint] Fetch Preprint Requests'; } +export class FetchPreprintRequestActions { + static readonly type = '[Preprint] Fetch Preprint Requests Actions'; + + constructor(public requestId: string) {} +} + export class WithdrawPreprint { static readonly type = '[Preprint] Withdraw Preprint'; @@ -45,6 +52,25 @@ export class WithdrawPreprint { ) {} } +export class SubmitReviewsDecision { + static readonly type = '[Preprint] Submit Reviews Decision'; + + constructor( + public trigger: string, + public comment: StringOrNull + ) {} +} + +export class SubmitRequestsDecision { + static readonly type = '[Preprint] Submit Request Decision'; + + constructor( + public requestId: string, + public trigger: string, + public comment: StringOrNull + ) {} +} + export class ResetState { static readonly type = '[Preprint] Reset State'; } diff --git a/src/app/features/preprints/store/preprint/preprint.model.ts b/src/app/features/preprints/store/preprint/preprint.model.ts index f66df3963..e59f0c9de 100644 --- a/src/app/features/preprints/store/preprint/preprint.model.ts +++ b/src/app/features/preprints/store/preprint/preprint.model.ts @@ -1,5 +1,5 @@ import { ReviewAction } from '@osf/features/moderation/models'; -import { Preprint, PreprintShortInfo } from '@osf/features/preprints/models'; +import { Preprint, PreprintRequestAction, PreprintShortInfo } from '@osf/features/preprints/models'; import { PreprintRequest } from '@osf/features/preprints/models/preprint-request.models'; import { AsyncStateModel, AsyncStateWithTotalCount, OsfFile, OsfFileVersion } from '@shared/models'; @@ -11,6 +11,7 @@ export interface PreprintStateModel { preprintVersionIds: AsyncStateModel; preprintReviewActions: AsyncStateModel; preprintRequests: AsyncStateModel; + preprintRequestsActions: AsyncStateModel; } export const DefaultState: PreprintStateModel = { @@ -52,4 +53,9 @@ export const DefaultState: PreprintStateModel = { isLoading: false, error: null, }, + preprintRequestsActions: { + data: [], + isLoading: false, + error: null, + }, }; diff --git a/src/app/features/preprints/store/preprint/preprint.selectors.ts b/src/app/features/preprints/store/preprint/preprint.selectors.ts index e5abe6d97..fa413fd7a 100644 --- a/src/app/features/preprints/store/preprint/preprint.selectors.ts +++ b/src/app/features/preprints/store/preprint/preprint.selectors.ts @@ -83,4 +83,14 @@ export class PreprintSelectors { static arePreprintRequestsLoading(state: PreprintStateModel) { return state.preprintRequests.isLoading; } + + @Selector([PreprintState]) + static getPreprintRequestActions(state: PreprintStateModel) { + return state.preprintRequestsActions.data; + } + + @Selector([PreprintState]) + static arePreprintRequestActionsLoading(state: PreprintStateModel) { + return state.preprintRequestsActions.isLoading; + } } diff --git a/src/app/features/preprints/store/preprint/preprint.state.ts b/src/app/features/preprints/store/preprint/preprint.state.ts index eb010e579..f68e61b83 100644 --- a/src/app/features/preprints/store/preprint/preprint.state.ts +++ b/src/app/features/preprints/store/preprint/preprint.state.ts @@ -1,5 +1,5 @@ import { Action, State, StateContext, Store } from '@ngxs/store'; -import { patch } from '@ngxs/store/operators'; +import { append, patch } from '@ngxs/store/operators'; import { tap } from 'rxjs'; import { catchError } from 'rxjs/operators'; @@ -15,10 +15,13 @@ import { FetchPreprintById, FetchPreprintFile, FetchPreprintFileVersions, + FetchPreprintRequestActions, FetchPreprintRequests, FetchPreprintReviewActions, FetchPreprintVersionIds, ResetState, + SubmitRequestsDecision, + SubmitReviewsDecision, WithdrawPreprint, } from './preprint.actions'; import { DefaultState, PreprintStateModel } from './preprint.model'; @@ -60,6 +63,9 @@ export class PreprintState { preprint: patch({ isLoading: true, data: null }), preprintFile: patch({ isLoading: true, data: null }), fileVersions: patch({ isLoading: true, data: [] }), + preprintReviewActions: patch({ isLoading: false, data: [] }), + preprintRequests: patch({ isLoading: false, data: [] }), + preprintRequestsActions: patch({ isLoading: false, data: [] }), }) ); @@ -164,6 +170,25 @@ export class PreprintState { ); } + @Action(FetchPreprintRequestActions) + fetchPreprintRequestsActions(ctx: StateContext, action: FetchPreprintRequestActions) { + ctx.setState(patch({ preprintRequestsActions: patch({ isLoading: true }) })); + + return this.preprintsService.getPreprintRequestActions(action.requestId).pipe( + tap((actions) => { + ctx.setState( + patch({ + preprintRequestsActions: patch({ + isLoading: false, + data: append(actions), + }), + }) + ); + }), + catchError((error) => handleSectionError(ctx, 'preprintRequestsActions', error)) + ); + } + @Action(WithdrawPreprint) withdrawPreprint(ctx: StateContext, action: WithdrawPreprint) { const preprintId = ctx.getState().preprint.data?.id; @@ -172,6 +197,22 @@ export class PreprintState { return this.preprintsService.withdrawPreprint(preprintId, action.justification); } + @Action(SubmitReviewsDecision) + submitReviewsDecision(ctx: StateContext, action: SubmitReviewsDecision) { + const preprintId = ctx.getState().preprint.data?.id; + if (!preprintId) return; + + return this.preprintsService.submitReviewsDecision(preprintId, action.trigger, action.comment); + } + + @Action(SubmitRequestsDecision) + submitRequestsDecision(ctx: StateContext, action: SubmitRequestsDecision) { + const preprintId = ctx.getState().preprint.data?.id; + if (!preprintId) return; + + return this.preprintsService.submitRequestsDecision(action.requestId, action.trigger, action.comment); + } + @Action(ResetState) resetState(ctx: StateContext) { ctx.setState(patch({ ...DefaultState })); diff --git a/src/app/shared/mappers/user/user.mapper.ts b/src/app/shared/mappers/user/user.mapper.ts index 153af5533..e6ee2550e 100644 --- a/src/app/shared/mappers/user/user.mapper.ts +++ b/src/app/shared/mappers/user/user.mapper.ts @@ -34,6 +34,7 @@ export class UserMapper { social: user.attributes.social, defaultRegionId: user.relationships?.default_region?.data?.id, allowIndexing: user.attributes?.allow_indexing, + canViewReviews: user.attributes.can_view_reviews === true, //do not simplify it }; } diff --git a/src/app/shared/models/user/user.models.ts b/src/app/shared/models/user/user.models.ts index bd3f685c5..0b34071c2 100644 --- a/src/app/shared/models/user/user.models.ts +++ b/src/app/shared/models/user/user.models.ts @@ -17,6 +17,7 @@ export interface User { defaultRegionId: string; allowIndexing: boolean | undefined; isModerator?: boolean; + canViewReviews: boolean; } export interface UserSettings { @@ -39,6 +40,7 @@ export interface UserGetResponse { social: Social; date_registered: string; allow_indexing?: boolean; + can_view_reviews: boolean; }; relationships: { default_region: { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index eca9b949d..6e1be8e00 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1189,7 +1189,9 @@ "public": "Public", "embargo": "Embargo", "pendingUpdates": "Pending Updates", - "pendingWithdrawal": "Pending Withdrawal" + "pendingWithdrawal": "Pending Withdrawal", + "approved": "Approved", + "declined": "Declined" }, "makeDecision": { "header": "Make decision", @@ -2039,6 +2041,10 @@ } }, "details": { + "reasonForWithdrawal": "Reason for withdrawal", + "originalPublicationDate": "Original Publication Date", + "publicationDoi": "Peer-reviewed Publication DOI", + "supplementalMaterials": "Supplemental Materials", "doi": { "title": "{{documentType}} DOI", "pendingDoiMinted": "DOIs are minted by a third party, and may take up to 24 hours to be registered.", @@ -2065,6 +2071,7 @@ "rejected": "Rejected", "pendingWithdrawal": "Pending withdrawal", "withdrawalRejected": "Withdrawal rejected", + "withdrawn": "Withdrawn", "messages": { "base": "{{name}} uses {{workflow}}. This {{documentType}}", "pendingPreModeration": "is not publicly available or searchable until approved by a moderator.", @@ -2088,6 +2095,77 @@ "preModerationNoticeAccepted": "{{pluralCapitalizedPreprintWord}} are a permanent part of the scholarly record. Withdrawal requests are subject to this service’s policy on {{singularPreprintWord}} version removal and at the discretion of the moderators.
This service uses pre-moderation. This request will be submitted to service moderators for review. If the request is approved, this {{singularPreprintWord}} version will be replaced by a tombstone page with metadata and the reason for withdrawal. This {{singularPreprintWord}} version will still be searchable by other users after removal.", "postModerationNotice": "{{pluralCapitalizedPreprintWord}} are a permanent part of the scholarly record. Withdrawal requests are subject to this service’s policy on {{singularPreprintWord}} version removal and at the discretion of the moderators.
This service uses post-moderation. This request will be submitted to service moderators for review. If the request is approved, this {{singularPreprintWord}} version will be replaced by a tombstone page with metadata and the reason for withdrawal. This {{singularPreprintWord}} version will still be searchable by other users after removal.", "noModerationNotice": "{{pluralCapitalizedPreprintWord}} are a permanent part of the scholarly record. Withdrawal requests are subject to this service’s policy on {{singularPreprintWord}} version removal and at the discretion of the moderators.
This request will be submitted to {{supportEmail}} for review and removal. If the request is approved, this {{singularPreprintWord}} version will be replaced by a tombstone page with metadata and the reason for withdrawal. This {{singularPreprintWord}} version will still be searchable by other users after removal." + }, + "moderationStatusBanner": { + "recentActivity": { + "pending": "submitted this {{documentType}} on", + "accepted": "accepted this {{documentType}} on", + "rejected": "rejected this {{documentType}} on", + "pendingWithdrawal": "requested to withdraw this {{documentType}} on", + "withdrawn": "withdrew this {{documentType}} on", + "automatic": { + "pending": "This {{documentType}} was submitted on", + "accepted": "This {{documentType}} was automatically accepted on" + } + } + }, + "decision": { + "noReasonProvided": "No reason provided", + "makeDecision": "Make decision", + "modifyDecision": "Modify decision", + "withdrawalReason": "View reason", + "header": { + "submitDecision": "Submit your decision", + "modifyDecision": "Modify your decision", + "withdrawalReason": "Reason for withdrawal" + }, + "settings": { + "comments": { + "private": "Comments are not visible to contributors", + "public": "Comments are visible to contributors on decision" + }, + "names": { + "anonymous": "Comments are anonymous", + "named": "Commenter's name is visible to contributors" + }, + "moderation": { + "pre": "Submission appears in search results once accepted", + "post": "Submission will be removed from search results and made private if rejected" + } + }, + "submitButton": { + "submitDecision": "Submit decision", + "modifyDecision": "Modify decision", + "updateComment": "Update comment" + }, + "accept": { + "label": "Accept submission", + "pre": "Submission will appear in search results and be made public.", + "post": "Submission will continue to appear in search results." + }, + "reject": { + "label": "Reject submission", + "pre": "Submission will not appear in search results and will remain private.", + "post": "Submission will be removed from search results and made private." + }, + "approve": { + "label": "Approve withdrawal", + "explanation": "Submission will be withdrawn but still have a tombstone page with a subset of the metadata available" + }, + "decline": { + "label": "Decline withdrawal", + "explanation": "Submission will remain fully public" + }, + "withdrawn": { + "label": "Withdraw submission", + "post": "Submission will no longer be publicly available." + }, + "commentPlaceholder": "Explain the reasoning behind your decision (optional)", + "commentLengthError": "Comment is {{difference}} character(s) too long (maximum is {{limit}}).", + "withdrawalJustification": "Reason for withdrawal (optional, will be publicly displayed)", + "denialJustification": "Reason for denial (required, not publicly visible)", + "justificationRequiredError": "Request decision justification can't be blank", + "justificationLengthError": "Request decision justification is too short (minimum is {{minLength}} characters)" } }, "preprintContributorsWarning": "Warning: Changing your permissions will prevent you from editing your draft.",