diff --git a/src/app/core/models/json-api.model.ts b/src/app/core/models/json-api.model.ts index 8f52461e..cdf0c28f 100644 --- a/src/app/core/models/json-api.model.ts +++ b/src/app/core/models/json-api.model.ts @@ -3,6 +3,10 @@ export interface JsonApiResponse { included?: Included; } +export interface JsonApiResponseWithMeta extends JsonApiResponse { + meta: Meta; +} + export interface JsonApiResponseWithPaging extends JsonApiResponse { links: { meta: MetaJsonApi; 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 new file mode 100644 index 00000000..d46396df --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.html @@ -0,0 +1,82 @@ + + @if (preprint()) { + @let preprintValue = preprint()!; +
+
+

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

+ + + + +

{{ license()!.name }}

+
+ +

{{ license()!.text | interpolate: licenseOptionsRecord() }}

+
+
+
+
+ +
+

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

+ +
+ @for (subject of subjects(); track subject.id) { + + } + + @if (areSelectedSubjectsLoading()) { + + } +
+
+ +
+

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

+ +
+ @for (tag of preprintValue.tags; track tag) { + + } @empty { +

{{ 'common.labels.none' | translate }}

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

{{ 'Original Publication Date' | translate }}

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

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

+ + {{ preprintValue.customPublicationCitation }} +
+ } + + +
+

Citation

+

Use shared component here

+
+
+ } + + @if (isPreprintLoading()) { +
+ @for (i of skeletonData; track $index) { +
+ + +
+ } +
+ } +
diff --git a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.scss b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.spec.ts b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.spec.ts new file mode 100644 index 00000000..0c07bc8a --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdditionalInfoComponent } from './additional-info.component'; + +describe('AdditionalInfoComponent', () => { + let component: AdditionalInfoComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AdditionalInfoComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AdditionalInfoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.ts b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.ts new file mode 100644 index 00000000..48f483da --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.ts @@ -0,0 +1,71 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Accordion, AccordionContent, AccordionHeader, AccordionPanel } from 'primeng/accordion'; +import { Card } from 'primeng/card'; +import { Skeleton } from 'primeng/skeleton'; +import { Tag } from 'primeng/tag'; + +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, effect } from '@angular/core'; + +import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; +import { FetchLicenses, FetchPreprintProject, SubmitPreprint } from '@osf/features/preprints/store/preprint-stepper'; +import { ResourceType } from '@shared/enums'; +import { InterpolatePipe } from '@shared/pipes'; +import { FetchSelectedSubjects, GetAllContributors, SubjectsSelectors } from '@shared/stores'; + +@Component({ + selector: 'osf-preprint-additional-info', + imports: [ + Card, + TranslatePipe, + Tag, + Skeleton, + DatePipe, + Accordion, + AccordionPanel, + AccordionHeader, + AccordionContent, + InterpolatePipe, + ], + templateUrl: './additional-info.component.html', + styleUrl: './additional-info.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AdditionalInfoComponent { + private actions = createDispatchMap({ + getContributors: GetAllContributors, + fetchSubjects: FetchSelectedSubjects, + fetchLicenses: FetchLicenses, + fetchPreprintProject: FetchPreprintProject, + submitPreprint: SubmitPreprint, + }); + + preprint = select(PreprintSelectors.getPreprint); + isPreprintLoading = select(PreprintSelectors.isPreprintLoading); + + subjects = select(SubjectsSelectors.getSelectedSubjects); + areSelectedSubjectsLoading = select(SubjectsSelectors.areSelectedSubjectsLoading); + + license = computed(() => { + const preprint = this.preprint(); + if (!preprint) return null; + return preprint.embeddedLicense; + }); + licenseOptionsRecord = computed(() => { + return (this.preprint()?.licenseOptions ?? {}) as Record; + }); + + skeletonData = Array.from({ length: 5 }, () => null); + + constructor() { + effect(() => { + const preprint = this.preprint(); + if (!preprint) return; + + this.actions.fetchSubjects(this.preprint()!.id, ResourceType.Preprint); + }); + } +} 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 new file mode 100644 index 00000000..1af38441 --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html @@ -0,0 +1,133 @@ + + @if (preprint()) { + @let preprintValue = preprint()!; +
+
+

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

+ +
+ + @if (affiliatedInstitutions().length) { +
+

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

+ +
+ @for (institution of affiliatedInstitutions(); track institution.id) { + Institution logo + } +
+
+ } + +
+

{{ 'Authors' | translate }}

+ +
+ @for (contributor of bibliographicContributors(); track contributor.id) { +
+ {{ contributor.fullName }} + {{ $last ? '' : ',' }} +
+ } + + @if (areContributorsLoading()) { + + } +
+
+ +
+

{{ 'preprints.preprintStepper.review.sections.authorAssertions.conflictOfInterest' | translate }}

+ + @if (!preprintValue.hasCoi) { +

{{ 'preprints.preprintStepper.review.sections.authorAssertions.noCoi' | translate }}

+ } @else { + {{ preprintValue.coiStatement }} + } +
+ +
+

{{ 'preprints.preprintStepper.review.sections.authorAssertions.publicData' | translate }}

+ + @switch (preprintValue.hasDataLinks) { + @case (ApplicabilityStatus.NotApplicable) { +

{{ 'preprints.preprintStepper.review.sections.authorAssertions.noData' | translate }}

+ } + @case (ApplicabilityStatus.Unavailable) { + {{ preprintValue.whyNoData }} + } + @case (ApplicabilityStatus.Applicable) { + @for (link of preprintValue.dataLinks; track $index) { +

{{ link }}

+ } + } + } +
+ +
+

+ {{ 'preprints.preprintStepper.review.sections.authorAssertions.publicPreregistration' | translate }} +

+ + @switch (preprintValue.hasPreregLinks) { + @case (ApplicabilityStatus.NotApplicable) { +

+ {{ 'preprints.preprintStepper.review.sections.authorAssertions.noPrereg' | translate }} +

+ } + @case (ApplicabilityStatus.Unavailable) { + {{ preprintValue.whyNoPrereg }} + } + @case (ApplicabilityStatus.Applicable) { + @switch (preprintValue.preregLinkInfo) { + @case (PreregLinkInfo.Analysis) { +

+ {{ 'preprints.preprintStepper.common.labels.preregTypes.analysis' | translate }} +

+ } + @case (PreregLinkInfo.Designs) { +

+ {{ 'preprints.preprintStepper.common.labels.preregTypes.designs' | translate }} +

+ } + @case (PreregLinkInfo.Both) { +

+ {{ 'preprints.preprintStepper.common.labels.preregTypes.both' | translate }} +

+ } + } + @for (link of preprintValue.preregLinks; track $index) { +

{{ link }}

+ } + } + } +
+ +
+

Preprint DOI

+ + +
+
+ } + + @if (isPreprintLoading()) { +
+ @for (i of skeletonData; track $index) { +
+ + +
+ } +
+ } +
diff --git a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.scss b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.spec.ts b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.spec.ts new file mode 100644 index 00000000..53268f6b --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GeneralInformationComponent } from './general-information.component'; + +describe('GeneralInformationComponent', () => { + let component: GeneralInformationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GeneralInformationComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(GeneralInformationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); 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 new file mode 100644 index 00000000..644aeec5 --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.ts @@ -0,0 +1,92 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Card } from 'primeng/card'; +import { Select } from 'primeng/select'; +import { Skeleton } from 'primeng/skeleton'; + +import { Location } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, OnDestroy, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; + +import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums'; +import { FetchPreprintById, PreprintSelectors } from '@osf/features/preprints/store/preprint'; +import { TruncatedTextComponent } from '@shared/components'; +import { ResourceType } from '@shared/enums'; +import { Institution } from '@shared/models'; +import { ContributorsSelectors, GetAllContributors, ResetContributorsState } from '@shared/stores'; + +@Component({ + selector: 'osf-preprint-general-information', + imports: [Card, TranslatePipe, TruncatedTextComponent, Skeleton, Select, FormsModule], + templateUrl: './general-information.component.html', + styleUrl: './general-information.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class GeneralInformationComponent implements OnDestroy { + private readonly router = inject(Router); + private readonly location = inject(Location); + readonly ApplicabilityStatus = ApplicabilityStatus; + readonly PreregLinkInfo = PreregLinkInfo; + + private actions = createDispatchMap({ + getContributors: GetAllContributors, + resetContributorsState: ResetContributorsState, + fetchPreprintById: FetchPreprintById, + }); + + preprint = select(PreprintSelectors.getPreprint); + isPreprintLoading = select(PreprintSelectors.isPreprintLoading); + + //[RNi] TODO: Implement when institutions available + affiliatedInstitutions = signal([]); + + contributors = select(ContributorsSelectors.getContributors); + areContributorsLoading = select(ContributorsSelectors.isContributorsLoading); + bibliographicContributors = computed(() => { + return this.contributors().filter((contributor) => contributor.isBibliographic); + }); + + preprintVersionIds = select(PreprintSelectors.getPreprintVersionIds); + arePreprintVersionIdsLoading = select(PreprintSelectors.arePreprintVersionIdsLoading); + + versionsDropdownOptions = computed(() => { + const preprintVersionIds = this.preprintVersionIds(); + if (!preprintVersionIds.length) return []; + + return preprintVersionIds.map((versionId, index) => ({ + label: `Version ${preprintVersionIds.length - index}`, + value: versionId, + })); + }); + + skeletonData = Array.from({ length: 5 }, () => null); + + constructor() { + effect(() => { + const preprint = this.preprint(); + if (!preprint) return; + + this.actions.getContributors(this.preprint()!.id, ResourceType.Preprint); + }); + } + + ngOnDestroy(): void { + this.actions.resetContributorsState(); + } + + 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); + }, + }); + } +} diff --git a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.html b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.html new file mode 100644 index 00000000..120fb39d --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.html @@ -0,0 +1,55 @@ +
+ @if (safeLink()) { + + } + @if (isIframeLoading || isFileLoading()) { + + } + +
+ @if (fileVersions().length) { + @let fileVersionsValue = fileVersions(); + +
+

{{ fileVersionsValue[0].name }}

+

Version: {{ fileVersionsValue[0].id }}

+ + + +
+ } + + @if (areFileVersionsLoading()) { + + } + + @if (file()) { + @let fileValue = file()!; + +
+ Submitted: {{ fileValue.dateCreated | date: 'longDate' }} + @if (isMedium() || isLarge()) { + | + } + Last edited: {{ fileValue.dateModified | date: 'longDate' }} +
+ } + + @if (isFileLoading()) { + + } +
+
diff --git a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.scss b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.scss new file mode 100644 index 00000000..79989691 --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.scss @@ -0,0 +1,17 @@ +.file-section-height { + min-height: 400px; + + &.medium { + min-height: 700px; + } + + &.large { + min-height: 1100px; + } +} + +.card { + &.small { + --p-card-body-padding: 0.75rem; + } +} diff --git a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.spec.ts new file mode 100644 index 00000000..627b4a4d --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PreprintFileSectionComponent } from './preprint-file-section.component'; + +describe('PreprintFileSectionComponent', () => { + let component: PreprintFileSectionComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PreprintFileSectionComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PreprintFileSectionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.ts b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.ts new file mode 100644 index 00000000..0dd01b7e --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.ts @@ -0,0 +1,53 @@ +import { select } from '@ngxs/store'; + +import { Button } from 'primeng/button'; +import { Menu } from 'primeng/menu'; +import { Skeleton } from 'primeng/skeleton'; + +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { DomSanitizer } from '@angular/platform-browser'; + +import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; +import { LoadingSpinnerComponent } from '@shared/components'; +import { IS_LARGE, IS_MEDIUM } from '@shared/utils'; + +@Component({ + selector: 'osf-preprint-file-section', + imports: [LoadingSpinnerComponent, DatePipe, Skeleton, Menu, Button], + templateUrl: './preprint-file-section.component.html', + styleUrl: './preprint-file-section.component.scss', + providers: [DatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PreprintFileSectionComponent { + private readonly sanitizer = inject(DomSanitizer); + private readonly datePipe = inject(DatePipe); + + isMedium = toSignal(inject(IS_MEDIUM)); + isLarge = toSignal(inject(IS_LARGE)); + + file = select(PreprintSelectors.getPreprintFile); + isFileLoading = select(PreprintSelectors.isPreprintFileLoading); + safeLink = computed(() => { + const link = this.file()?.links.render; + if (!link) return null; + + return this.sanitizer.bypassSecurityTrustResourceUrl(link); + }); + isIframeLoading = true; + + fileVersions = select(PreprintSelectors.getPreprintFileVersions); + areFileVersionsLoading = select(PreprintSelectors.arePreprintFileVersionsLoading); + + versionMenuItems = computed(() => { + const fileVersions = this.fileVersions(); + if (!fileVersions.length) return []; + + return fileVersions.map((version, index) => ({ + label: `Version ${++index}, ${this.datePipe.transform(version.dateCreated, 'mm/dd/yyyy hh:mm:ss')}`, + url: version.downloadLink, + })); + }); +} diff --git a/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.html b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.html new file mode 100644 index 00000000..964920e5 --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.html @@ -0,0 +1,41 @@ + +
+ +
+ @if (preprint()) { + Download preprint + } + + @if (metrics()) { +
+ Views: {{ metrics()!.views }} + | + Downloads: {{ metrics()!.downloads }} +
+ } + + @if (isPreprintLoading()) { + + + } +
+ + +
+
diff --git a/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.scss b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.scss new file mode 100644 index 00000000..6c6740f9 --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.scss @@ -0,0 +1,22 @@ +@use "assets/styles/mixins" as mix; +@use "assets/styles/variables" as var; + +.social-link { + background-color: var(--pr-blue-1); + border-radius: mix.rem(8px); + color: var(--white); + padding: mix.rem(7px); + width: mix.rem(36px); + height: mix.rem(36px); + + &:hover { + background-color: var(--pr-blue-3); + text-decoration: none; + } +} + +.card { + @media (max-width: var.$breakpoint-sm) { + --p-card-body-padding: 0.75rem; + } +} diff --git a/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.spec.ts b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.spec.ts new file mode 100644 index 00000000..f5f713b5 --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ShareAndDownloadComponent } from './share-and-download.component'; + +describe('ShareAndDownlaodComponent', () => { + let component: ShareAndDownloadComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ShareAndDownloadComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ShareAndDownloadComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.ts b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.ts new file mode 100644 index 00000000..1eb309ac --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.ts @@ -0,0 +1,40 @@ +import { select } from '@ngxs/store'; + +import { ButtonDirective } from 'primeng/button'; +import { Card } from 'primeng/card'; +import { Skeleton } from 'primeng/skeleton'; + +import { ChangeDetectionStrategy, Component, computed } from '@angular/core'; + +import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; +import { IconComponent } from '@shared/components'; + +import { environment } from 'src/environments/environment'; + +@Component({ + selector: 'osf-preprint-share-and-download', + imports: [Card, IconComponent, Skeleton, ButtonDirective], + templateUrl: './share-and-download.component.html', + styleUrl: './share-and-download.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ShareAndDownloadComponent { + preprint = select(PreprintSelectors.getPreprint); + isPreprintLoading = select(PreprintSelectors.isPreprintLoading); + + metrics = computed(() => { + const preprint = this.preprint(); + + if (!preprint) return null; + + return preprint.metrics!; + }); + + downloadLink = computed(() => { + const preprint = this.preprint(); + + if (!preprint) return '#'; + + return `${environment.webUrl}/${this.preprint()?.id}/download/`; + }); +} diff --git a/src/app/features/preprints/components/stepper/review-step/review-step.component.html b/src/app/features/preprints/components/stepper/review-step/review-step.component.html index 3f81f20b..3da1fbd4 100644 --- a/src/app/features/preprints/components/stepper/review-step/review-step.component.html +++ b/src/app/features/preprints/components/stepper/review-step/review-step.component.html @@ -80,10 +80,10 @@

{{ 'preprints.preprintStepper.review.sections.metadata.license' | translate -
{{ license()?.name }}
+

{{ license()?.name }}

-
{{ license()!.text | interpolate: licenseOptionsRecord() }}
+

{{ license()!.text | interpolate: licenseOptionsRecord() }}

@@ -234,11 +234,13 @@

{{ 'preprints.preprintStepper.review.sections.supplements.title' | translate [label]="'common.buttons.cancel' | translate" severity="info" (click)="cancelSubmission()" + [disabled]="isPreprintSubmitting()" /> 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 8636fbd8..ee80a534 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 @@ -58,6 +58,7 @@ export class ReviewStepComponent implements OnInit { }); provider = input.required(); preprint = select(PreprintStepperSelectors.getPreprint); + isPreprintSubmitting = select(PreprintStepperSelectors.isPreprintSubmitting); contributors = select(ContributorsSelectors.getContributors); bibliographicContributors = computed(() => { @@ -85,7 +86,7 @@ export class ReviewStepComponent implements OnInit { this.actions.submitPreprint().subscribe({ complete: () => { this.toastService.showSuccess('preprints.preprintStepper.common.successMessages.preprintSubmitted'); - this.router.navigateByUrl('/preprints'); + this.router.navigate(['/preprints', this.provider()!.id, this.preprint()!.id]); }, }); } diff --git a/src/app/features/preprints/mappers/preprints.mapper.ts b/src/app/features/preprints/mappers/preprints.mapper.ts index 34c7f495..5d5cb598 100644 --- a/src/app/features/preprints/mappers/preprints.mapper.ts +++ b/src/app/features/preprints/mappers/preprints.mapper.ts @@ -1,11 +1,13 @@ -import { ApiData, JsonApiResponseWithPaging } from '@core/models'; +import { ApiData, JsonApiResponseWithMeta, JsonApiResponseWithPaging } from '@core/models'; import { Preprint, PreprintAttributesJsonApi, PreprintEmbedsJsonApi, + PreprintMetaJsonApi, PreprintRelationshipsJsonApi, PreprintShortInfoWithTotalCount, } from '@osf/features/preprints/models'; +import { LicensesMapper } from '@shared/mappers'; export class PreprintsMapper { static toCreatePayload(title: string, abstract: string, providerId: string) { @@ -66,6 +68,55 @@ export class PreprintsMapper { }; } + static fromPreprintWithEmbedsJsonApi( + response: JsonApiResponseWithMeta< + ApiData, + PreprintMetaJsonApi, + null + > + ): Preprint { + const data = response.data; + const meta = response.meta; + return { + id: data.id, + dateCreated: data.attributes.date_created, + dateModified: data.attributes.date_modified, + title: data.attributes.title, + description: data.attributes.description, + doi: data.attributes.doi, + customPublicationCitation: data.attributes.custom_publication_citation, + originalPublicationDate: data.attributes.original_publication_date, + isPublished: data.attributes.is_published, + tags: data.attributes.tags, + isPublic: data.attributes.public, + version: data.attributes.version, + isLatestVersion: data.attributes.is_latest_version, + primaryFileId: data.relationships.primary_file?.data?.id || null, + nodeId: data.relationships.node?.data?.id, + licenseId: data.relationships.license?.data?.id || null, + licenseOptions: data.attributes.license_record + ? { + year: data.attributes.license_record.year, + copyrightHolders: data.attributes.license_record.copyright_holders.join(','), + } + : null, + hasCoi: data.attributes.has_coi, + coiStatement: data.attributes.conflict_of_interest_statement, + hasDataLinks: data.attributes.has_data_links, + dataLinks: data.attributes.data_links, + whyNoData: data.attributes.why_no_data, + hasPreregLinks: data.attributes.has_prereg_links, + whyNoPrereg: data.attributes.why_no_prereg, + preregLinks: data.attributes.prereg_links, + preregLinkInfo: data.attributes.prereg_link_info, + metrics: { + downloads: meta.metrics.downloads, + views: meta.metrics.views, + }, + embeddedLicense: LicensesMapper.fromLicenseDataJsonApi(data.embeds.license.data), + }; + } + static toSubmitPreprintPayload(preprintId: string) { return { data: { @@ -86,7 +137,10 @@ export class PreprintsMapper { } static fromMyPreprintJsonApi( - response: JsonApiResponseWithPaging[], null> + response: JsonApiResponseWithPaging< + ApiData[], + null + > ): PreprintShortInfoWithTotalCount { return { data: response.data.map((preprintData) => { @@ -100,6 +154,7 @@ export class PreprintsMapper { name: contrData.embeds.users.data.attributes.full_name, }; }), + providerId: preprintData.relationships.provider.data.id, }; }), totalCount: response.links.meta.total, 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 36fa5ab8..e733a505 100644 --- a/src/app/features/preprints/models/preprint-json-api.models.ts +++ b/src/app/features/preprints/models/preprint-json-api.models.ts @@ -1,6 +1,6 @@ import { BooleanOrNull, StringOrNull } from '@core/helpers'; import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums'; -import { ContributorResponse, LicenseRecordJsonApi } from '@shared/models'; +import { ContributorResponse, LicenseRecordJsonApi, LicenseResponseJsonApi } from '@shared/models'; export interface PreprintAttributesJsonApi { date_created: string; @@ -53,10 +53,24 @@ export interface PreprintRelationshipsJsonApi { type: 'nodes'; }; }; + provider: { + data: { + id: string; + type: 'preprint-providers'; + }; + }; } export interface PreprintEmbedsJsonApi { bibliographic_contributors: { data: ContributorResponse[]; }; + license: LicenseResponseJsonApi; +} + +export interface PreprintMetaJsonApi { + metrics: { + downloads: number; + views: number; + }; } diff --git a/src/app/features/preprints/models/preprint.models.ts b/src/app/features/preprints/models/preprint.models.ts index ee93fc53..3978466e 100644 --- a/src/app/features/preprints/models/preprint.models.ts +++ b/src/app/features/preprints/models/preprint.models.ts @@ -1,6 +1,6 @@ import { BooleanOrNull, StringOrNull } from '@core/helpers'; import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums'; -import { IdName, LicenseOptions } from '@shared/models'; +import { IdName, License, LicenseOptions } from '@shared/models'; export interface Preprint { id: string; @@ -29,6 +29,8 @@ export interface Preprint { whyNoPrereg: StringOrNull; preregLinks: string[]; preregLinkInfo: PreregLinkInfo | null; + metrics?: PreprintMetrics; + embeddedLicense?: License; } export interface PreprintFilesLinks { @@ -41,9 +43,15 @@ export interface PreprintShortInfo { title: string; dateModified: string; contributors: IdName[]; + providerId: string; } export interface PreprintShortInfoWithTotalCount { data: PreprintShortInfo[]; totalCount: number; } + +export interface PreprintMetrics { + downloads: number; + views: number; +} diff --git a/src/app/features/preprints/pages/create-new-version/create-new-version.component.html b/src/app/features/preprints/pages/create-new-version/create-new-version.component.html index 2970e5ae..d9b4834c 100644 --- a/src/app/features/preprints/pages/create-new-version/create-new-version.component.html +++ b/src/app/features/preprints/pages/create-new-version/create-new-version.component.html @@ -30,20 +30,17 @@

} - -@if (!this.initMode()) { -
- @switch (currentStep().value) { - @case (PreprintSteps.File) { - - } - @case (PreprintSteps.Review) { - - } +
+ @switch (currentStep().value) { + @case (PreprintSteps.File) { + } -
-} + @case (PreprintSteps.Review) { + + } + } +
diff --git a/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts b/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts index f070a5df..c88ea7ee 100644 --- a/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts +++ b/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts @@ -25,7 +25,6 @@ import { createNewVersionStepsConst } from '@osf/features/preprints/constants'; import { PreprintSteps } from '@osf/features/preprints/enums'; import { GetPreprintProviderById, PreprintProvidersSelectors } from '@osf/features/preprints/store/preprint-providers'; import { - CreateNewVersion, FetchPreprintById, PreprintStepperSelectors, ResetState, @@ -56,7 +55,6 @@ export class CreateNewVersionComponent implements OnInit, OnDestroy, CanDeactiva setSelectedPreprintProviderId: SetSelectedPreprintProviderId, resetState: ResetState, fetchPreprint: FetchPreprintById, - createNewVersion: CreateNewVersion, }); readonly PreprintSteps = PreprintSteps; @@ -68,7 +66,6 @@ export class CreateNewVersionComponent implements OnInit, OnDestroy, CanDeactiva hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); currentStep = signal(createNewVersionStepsConst[0]); isWeb = toSignal(inject(IS_WEB)); - initMode = signal(true); constructor() { effect(() => { @@ -85,20 +82,6 @@ export class CreateNewVersionComponent implements OnInit, OnDestroy, CanDeactiva BrowserTabHelper.updateTabStyles(provider.faviconUrl, provider.name); } }); - - effect(() => { - //[RNi] TODO: move this logic to handler of "Create New Version" button on preprint details page when implemented - const preprint = this.preprint(); - - if (!this.initMode()) return; - if (!preprint) return; - - this.actions.createNewVersion(preprint.id).subscribe({ - complete: () => { - this.initMode.set(false); - }, - }); - }); } ngOnInit() { diff --git a/src/app/features/preprints/pages/my-preprints/my-preprints.component.ts b/src/app/features/preprints/pages/my-preprints/my-preprints.component.ts index ab0c5fa0..adde8b36 100644 --- a/src/app/features/preprints/pages/my-preprints/my-preprints.component.ts +++ b/src/app/features/preprints/pages/my-preprints/my-preprints.component.ts @@ -24,7 +24,7 @@ import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { parseQueryFilterParams } from '@core/helpers'; -import { Preprint, PreprintShortInfo } from '@osf/features/preprints/models'; +import { PreprintShortInfo } from '@osf/features/preprints/models'; import { FetchMyPreprints, PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { ListInfoShortenerComponent, SearchInputComponent, SubHeaderComponent } from '@shared/components'; import { TABLE_PARAMS } from '@shared/constants'; @@ -79,8 +79,8 @@ export class MyPreprintsComponent { this.setupQueryParamsEffect(); } - navigateToPreprintDetails(preprint: Preprint): void { - //[RNi] TODO: Implement redirect when details page is done + navigateToPreprintDetails(preprint: PreprintShortInfo): void { + this.router.navigateByUrl(`/preprints/${preprint.providerId}/${preprint.id}`); } onPageChange(event: TablePageEvent): void { 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 new file mode 100644 index 00000000..ee0f7390 --- /dev/null +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.html @@ -0,0 +1,41 @@ +
+
+ @if (isPreprintProviderLoading() || isPreprintLoading()) { + + + } @else { + Provider Logo +

{{ preprint()!.title }}

+ } +
+ +
+ + + +
+
+ +
+
+ +

Status banner here

+
+ +
+
+ +
+ +
+ + + +
+
+
diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.scss b/src/app/features/preprints/pages/preprint-details/preprint-details.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts new file mode 100644 index 00000000..68e01a42 --- /dev/null +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PreprintDetailsComponent } from './preprint-details.component'; + +describe('PreprintDetailsComponent', () => { + let component: PreprintDetailsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PreprintDetailsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PreprintDetailsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); 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 new file mode 100644 index 00000000..93b7e368 --- /dev/null +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts @@ -0,0 +1,77 @@ +import { createDispatchMap, select, Store } from '@ngxs/store'; + +import { Button } from 'primeng/button'; +import { Skeleton } from 'primeng/skeleton'; + +import { map, of } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, HostBinding, inject, OnDestroy, OnInit } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { AdditionalInfoComponent } from '@osf/features/preprints/components/preprint-details/additional-info/additional-info.component'; +import { GeneralInformationComponent } from '@osf/features/preprints/components/preprint-details/general-information/general-information.component'; +import { PreprintFileSectionComponent } from '@osf/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component'; +import { ShareAndDownloadComponent } from '@osf/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component'; +import { FetchPreprintById, PreprintSelectors, ResetState } 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'; + +@Component({ + selector: 'osf-preprint-details', + imports: [ + Skeleton, + PreprintFileSectionComponent, + Button, + ShareAndDownloadComponent, + GeneralInformationComponent, + AdditionalInfoComponent, + ], + templateUrl: './preprint-details.component.html', + styleUrl: './preprint-details.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PreprintDetailsComponent implements OnInit, OnDestroy { + @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; + + private readonly route = inject(ActivatedRoute); + private readonly store = inject(Store); + private readonly router = inject(Router); + + private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); + private preprintId = toSignal(this.route.params.pipe(map((params) => params['preprintId'])) ?? of(undefined)); + + private actions = createDispatchMap({ + getPreprintProviderById: GetPreprintProviderById, + resetState: ResetState, + fetchPreprintById: FetchPreprintById, + createNewVersion: CreateNewVersion, + }); + + preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); + isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); + preprint = select(PreprintSelectors.getPreprint); + isPreprintLoading = select(PreprintSelectors.isPreprintLoading); + + ngOnInit() { + this.actions.fetchPreprintById(this.preprintId()); + this.actions.getPreprintProviderById(this.providerId()); + } + + ngOnDestroy() { + this.actions.resetState(); + } + + editPreprintClicked() { + this.router.navigate(['preprints', this.providerId(), 'edit', this.preprintId()]); + } + + createNewVersionClicked() { + this.actions.createNewVersion(this.preprintId()).subscribe({ + complete: () => { + const newVersionPreprint = this.store.selectSnapshot(PreprintStepperSelectors.getPreprint); + this.router.navigate(['preprints', this.providerId(), 'new-version', newVersionPreprint!.id]); + }, + }); + } +} diff --git a/src/app/features/preprints/preprints.routes.ts b/src/app/features/preprints/preprints.routes.ts index 1dde64cd..3580625c 100644 --- a/src/app/features/preprints/preprints.routes.ts +++ b/src/app/features/preprints/preprints.routes.ts @@ -108,6 +108,13 @@ export const preprintsRoutes: Routes = [ ), canDeactivate: [ConfirmLeavingGuard], }, + { + path: ':providerId/:preprintId', + loadComponent: () => + import('@osf/features/preprints/pages/preprint-details/preprint-details.component').then( + (c) => c.PreprintDetailsComponent + ), + }, ], }, ]; diff --git a/src/app/features/preprints/services/preprint-files.service.ts b/src/app/features/preprints/services/preprint-files.service.ts index a019b275..ac0c3f70 100644 --- a/src/app/features/preprints/services/preprint-files.service.ts +++ b/src/app/features/preprints/services/preprint-files.service.ts @@ -3,7 +3,7 @@ import { map, Observable, switchMap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { JsonApiService } from '@core/services'; -import { ApiData, JsonApiResponse } from '@osf/core/models'; +import { ApiData } from '@osf/core/models'; import { PreprintsMapper } from '@osf/features/preprints/mappers'; import { Preprint, @@ -64,7 +64,7 @@ export class PreprintFilesService { return this.jsonApiService.get(`${environment.apiUrl}/nodes/${projectId}/files/`).pipe( switchMap((response: GetFilesResponse) => { return this.jsonApiService - .get>(response.data[0].relationships.root_folder.links.related.href) + .get(response.data[0].relationships.root_folder.links.related.href) .pipe( switchMap((fileResponse) => this.filesService.getFilesWithoutFiltering(fileResponse.data.relationships.files.links.related.href) diff --git a/src/app/features/preprints/services/preprints.service.ts b/src/app/features/preprints/services/preprints.service.ts index 9d9a5ffd..6291e4cf 100644 --- a/src/app/features/preprints/services/preprints.service.ts +++ b/src/app/features/preprints/services/preprints.service.ts @@ -3,13 +3,14 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { JsonApiService } from '@core/services'; -import { ApiData, JsonApiResponse, JsonApiResponseWithPaging } from '@osf/core/models'; +import { ApiData, JsonApiResponse, JsonApiResponseWithMeta, JsonApiResponseWithPaging } from '@osf/core/models'; import { preprintSortFieldMap } from '@osf/features/preprints/constants'; import { PreprintsMapper } from '@osf/features/preprints/mappers'; import { Preprint, PreprintAttributesJsonApi, PreprintEmbedsJsonApi, + PreprintMetaJsonApi, PreprintRelationshipsJsonApi, } from '@osf/features/preprints/models'; import { SearchFilters } from '@shared/models'; @@ -66,6 +67,27 @@ export class PreprintsService { ); } + getByIdWithEmbeds(id: string) { + const params = { + 'metrics[views]': 'total', + 'metrics[downloads]': 'total', + 'embed[]': 'license', + }; + return this.jsonApiService + .get< + JsonApiResponseWithMeta< + ApiData, + PreprintMetaJsonApi, + null + > + >(`${environment.apiUrl}/preprints/${id}/`, params) + .pipe( + map((response) => { + return PreprintsMapper.fromPreprintWithEmbedsJsonApi(response); + }) + ); + } + deletePreprint(id: string) { return this.jsonApiService.delete(`${environment.apiUrl}/preprints/${id}/`); } @@ -110,18 +132,29 @@ export class PreprintsService { return apiPayload; } + getPreprintVersionIds(preprintId: string): Observable { + return this.jsonApiService + .get< + JsonApiResponseWithPaging[], null> + >(`${environment.apiUrl}/preprints/${preprintId}/versions/`) + .pipe(map((response) => response.data.map((data) => data.id))); + } + getMyPreprints(pageNumber: number, pageSize: number, filters: SearchFilters) { const params: Record = { 'embed[]': ['bibliographic_contributors'], 'fields[users]': 'family_name,full_name,given_name,middle_name', - 'fields[preprints]': 'title,date_modified,public,bibliographic_contributors', + 'fields[preprints]': 'title,date_modified,public,bibliographic_contributors,provider', }; searchPreferencesToJsonApiQueryParams(params, pageNumber, pageSize, filters, preprintSortFieldMap); return this.jsonApiService .get< - JsonApiResponseWithPaging[], null> + JsonApiResponseWithPaging< + ApiData[], + null + > >(`${environment.apiUrl}/users/me/preprints/`, params) .pipe(map((response) => PreprintsMapper.fromMyPreprintJsonApi(response))); } diff --git a/src/app/features/preprints/store/preprint/preprint.actions.ts b/src/app/features/preprints/store/preprint/preprint.actions.ts index 3fbaf5c6..1b17c551 100644 --- a/src/app/features/preprints/store/preprint/preprint.actions.ts +++ b/src/app/features/preprints/store/preprint/preprint.actions.ts @@ -15,3 +15,19 @@ export class FetchPreprintById { constructor(public id: string) {} } + +export class FetchPreprintFile { + static readonly type = '[Preprint] Fetch Preprint File'; +} + +export class FetchPreprintFileVersions { + static readonly type = '[Preprint] Fetch Preprint File Versions'; +} + +export class FetchPreprintVersionIds { + static readonly type = '[Preprint] Fetch Preprint Version Ids'; +} + +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 e9dd437e..377191f2 100644 --- a/src/app/features/preprints/store/preprint/preprint.model.ts +++ b/src/app/features/preprints/store/preprint/preprint.model.ts @@ -1,9 +1,12 @@ import { Preprint, PreprintShortInfo } from '@osf/features/preprints/models'; -import { AsyncStateModel, AsyncStateWithTotalCount } from '@shared/models'; +import { AsyncStateModel, AsyncStateWithTotalCount, OsfFile, OsfFileVersion } from '@shared/models'; export interface PreprintStateModel { myPreprints: AsyncStateWithTotalCount; preprint: AsyncStateModel; + preprintFile: AsyncStateModel; + fileVersions: AsyncStateModel; + preprintVersionIds: AsyncStateModel; } export const DefaultState: PreprintStateModel = { @@ -19,4 +22,20 @@ export const DefaultState: PreprintStateModel = { error: null, totalCount: 0, }, + preprintFile: { + data: null, + isLoading: false, + error: null, + isSubmitting: false, + }, + fileVersions: { + data: [], + isLoading: false, + error: null, + }, + preprintVersionIds: { + 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 206c8d78..fcc3ed0c 100644 --- a/src/app/features/preprints/store/preprint/preprint.selectors.ts +++ b/src/app/features/preprints/store/preprint/preprint.selectors.ts @@ -28,4 +28,39 @@ export class PreprintSelectors { static isPreprintSubmitting(state: PreprintStateModel) { return state.preprint.isSubmitting; } + + @Selector([PreprintState]) + static isPreprintLoading(state: PreprintStateModel) { + return state.preprint.isLoading; + } + + @Selector([PreprintState]) + static getPreprintFile(state: PreprintStateModel) { + return state.preprintFile.data; + } + + @Selector([PreprintState]) + static isPreprintFileLoading(state: PreprintStateModel) { + return state.preprintFile.isLoading; + } + + @Selector([PreprintState]) + static getPreprintFileVersions(state: PreprintStateModel) { + return state.fileVersions.data; + } + + @Selector([PreprintState]) + static arePreprintFileVersionsLoading(state: PreprintStateModel) { + return state.fileVersions.isLoading; + } + + @Selector([PreprintState]) + static getPreprintVersionIds(state: PreprintStateModel) { + return state.preprintVersionIds.data; + } + + @Selector([PreprintState]) + static arePreprintVersionIdsLoading(state: PreprintStateModel) { + return state.preprintVersionIds.isLoading; + } } diff --git a/src/app/features/preprints/store/preprint/preprint.state.ts b/src/app/features/preprints/store/preprint/preprint.state.ts index 9f104b5a..331722df 100644 --- a/src/app/features/preprints/store/preprint/preprint.state.ts +++ b/src/app/features/preprints/store/preprint/preprint.state.ts @@ -1,4 +1,4 @@ -import { Action, State, StateContext } from '@ngxs/store'; +import { Action, State, StateContext, Store } from '@ngxs/store'; import { patch } from '@ngxs/store/operators'; import { tap } from 'rxjs'; @@ -8,8 +8,16 @@ import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@core/handlers'; import { PreprintsService } from '@osf/features/preprints/services'; +import { FilesService } from '@shared/services'; -import { FetchMyPreprints, FetchPreprintById } from './preprint.actions'; +import { + FetchMyPreprints, + FetchPreprintById, + FetchPreprintFile, + FetchPreprintFileVersions, + FetchPreprintVersionIds, + ResetState, +} from './preprint.actions'; import { DefaultState, PreprintStateModel } from './preprint.model'; @State({ @@ -18,7 +26,9 @@ import { DefaultState, PreprintStateModel } from './preprint.model'; }) @Injectable() export class PreprintState { + private store = inject(Store); private preprintsService = inject(PreprintsService); + private fileService = inject(FilesService); @Action(FetchMyPreprints) fetchMyPreprints(ctx: StateContext, action: FetchMyPreprints) { @@ -41,14 +51,72 @@ export class PreprintState { } @Action(FetchPreprintById) - getPreprintById(ctx: StateContext, action: FetchPreprintById) { - ctx.setState(patch({ preprint: patch({ isLoading: true }) })); + fetchPreprintById(ctx: StateContext, action: FetchPreprintById) { + ctx.setState( + patch({ + preprint: patch({ isLoading: true, data: null }), + preprintFile: patch({ isLoading: true, data: null }), + fileVersions: patch({ isLoading: true, data: [] }), + }) + ); - return this.preprintsService.getById(action.id).pipe( + return this.preprintsService.getByIdWithEmbeds(action.id).pipe( tap((preprint) => { ctx.setState(patch({ preprint: patch({ isLoading: false, data: preprint }) })); + this.store.dispatch(new FetchPreprintFile()); + this.store.dispatch(new FetchPreprintVersionIds()); }), catchError((error) => handleSectionError(ctx, 'preprint', error)) ); } + + @Action(FetchPreprintFile) + fetchPreprintFile(ctx: StateContext) { + const preprintFileId = ctx.getState().preprint.data?.primaryFileId; + if (!preprintFileId) return; + ctx.setState(patch({ preprintFile: patch({ isLoading: true }) })); + + return this.fileService.getFileById(preprintFileId!).pipe( + tap((file) => { + ctx.setState(patch({ preprintFile: patch({ isLoading: false, data: file }) })); + this.store.dispatch(new FetchPreprintFileVersions()); + }), + catchError((error) => handleSectionError(ctx, 'preprintFile', error)) + ); + } + + @Action(FetchPreprintFileVersions) + fetchPreprintFileVersions(ctx: StateContext) { + const fileId = ctx.getState().preprintFile.data?.id; + if (!fileId) return; + + ctx.setState(patch({ fileVersions: patch({ isLoading: true }) })); + + return this.fileService.getFileVersions(fileId).pipe( + tap((fileVersions) => { + ctx.setState(patch({ fileVersions: patch({ isLoading: false, data: fileVersions }) })); + }), + catchError((error) => handleSectionError(ctx, 'fileVersions', error)) + ); + } + + @Action(FetchPreprintVersionIds) + fetchPreprintVersionIds(ctx: StateContext) { + const preprintId = ctx.getState().preprint.data?.id; + if (!preprintId) return; + + ctx.setState(patch({ preprintVersionIds: patch({ isLoading: true }) })); + + return this.preprintsService.getPreprintVersionIds(preprintId).pipe( + tap((versionIds) => { + ctx.setState(patch({ preprintVersionIds: patch({ isLoading: false, data: versionIds }) })); + }), + catchError((error) => handleSectionError(ctx, 'preprintVersionIds', error)) + ); + } + + @Action(ResetState) + resetState(ctx: StateContext) { + ctx.setState(patch({ ...DefaultState })); + } } diff --git a/src/app/shared/mappers/files/files.mapper.ts b/src/app/shared/mappers/files/files.mapper.ts index 7a4bde08..7ed7e83d 100644 --- a/src/app/shared/mappers/files/files.mapper.ts +++ b/src/app/shared/mappers/files/files.mapper.ts @@ -1,6 +1,13 @@ import { ApiData } from '@core/models'; import { FileTargetResponse } from '@osf/features/project/files/models/responses/get-file-target-response.model'; -import { FileLinks, FileRelationshipsResponse, FileResponse, OsfFile } from '@osf/shared/models'; +import { + FileLinks, + FileRelationshipsResponse, + FileResponse, + FileVersionsResponseJsonApi, + OsfFile, + OsfFileVersion, +} from '@osf/shared/models'; export function MapFiles( files: ApiData[] @@ -57,3 +64,15 @@ export function MapFile( }, } as OsfFile; } + +export function MapFileVersions(fileVersions: FileVersionsResponseJsonApi): OsfFileVersion[] { + return fileVersions.data.map((fileVersion) => { + return { + id: fileVersion.id, + size: fileVersion.attributes.size, + dateCreated: fileVersion.attributes.date_created, + name: fileVersion.attributes.name, + downloadLink: fileVersion.links.download, + }; + }); +} diff --git a/src/app/shared/mappers/licenses.mapper.ts b/src/app/shared/mappers/licenses.mapper.ts index 1e6d8303..bdecce28 100644 --- a/src/app/shared/mappers/licenses.mapper.ts +++ b/src/app/shared/mappers/licenses.mapper.ts @@ -1,14 +1,18 @@ import { License } from '@shared/models/license.model'; -import { LicensesResponseJsonApi } from '@shared/models/licenses-json-api.model'; +import { LicenseDataJsonApi, LicensesResponseJsonApi } from '@shared/models/licenses-json-api.model'; export class LicensesMapper { static fromLicensesResponse(response: LicensesResponseJsonApi): License[] { - return response.data.map((item) => ({ - id: item.id, - name: item.attributes.name, - requiredFields: item.attributes.required_fields, - url: item.attributes.url, - text: item.attributes.text, - })); + return response.data.map((item) => LicensesMapper.fromLicenseDataJsonApi(item)); + } + + static fromLicenseDataJsonApi(data: LicenseDataJsonApi): License { + return { + id: data.id, + name: data.attributes.name, + requiredFields: data.attributes.required_fields, + url: data.attributes.url, + text: data.attributes.text, + }; } } diff --git a/src/app/shared/models/files/file-version-json-api.model.ts b/src/app/shared/models/files/file-version-json-api.model.ts new file mode 100644 index 00000000..15122aee --- /dev/null +++ b/src/app/shared/models/files/file-version-json-api.model.ts @@ -0,0 +1,17 @@ +import { ApiData, JsonApiResponse } from '@core/models'; + +export type FileVersionsResponseJsonApi = JsonApiResponse< + ApiData[], + null +>; + +export interface FileVersionAttributesJsonApi { + size: number; + content_type: string; + date_created: Date; + name: string; +} + +export interface FileVersionLinksJsonApi { + download: string; +} diff --git a/src/app/shared/models/files/file-version.model.ts b/src/app/shared/models/files/file-version.model.ts new file mode 100644 index 00000000..dfcf5855 --- /dev/null +++ b/src/app/shared/models/files/file-version.model.ts @@ -0,0 +1,7 @@ +export interface OsfFileVersion { + id: string; + size: number; + dateCreated: Date; + name: string; + downloadLink: string; +} diff --git a/src/app/shared/models/files/get-files-response.model.ts b/src/app/shared/models/files/get-files-response.model.ts index 22b774ce..bc345711 100644 --- a/src/app/shared/models/files/get-files-response.model.ts +++ b/src/app/shared/models/files/get-files-response.model.ts @@ -1,11 +1,9 @@ import { ApiData, JsonApiResponse } from '@core/models'; import { FileTargetResponse } from '@osf/features/project/files/models/responses/get-file-target-response.model'; -export type GetFilesResponse = JsonApiResponse< - ApiData[], - null ->; -export type GetFileResponse = ApiData; +export type GetFilesResponse = JsonApiResponse; +export type GetFileResponse = JsonApiResponse; +export type FileData = ApiData; export type AddFileResponse = ApiData; export interface FileResponse { diff --git a/src/app/shared/models/files/index.ts b/src/app/shared/models/files/index.ts index 9a106b31..5335894f 100644 --- a/src/app/shared/models/files/index.ts +++ b/src/app/shared/models/files/index.ts @@ -1,6 +1,8 @@ export * from './file.model'; export * from './file-menu-action.model'; export * from './file-payload-json-api.model'; +export * from './file-version.model'; +export * from './file-version-json-api.model'; export * from './files-tree-actions.interface'; export * from './get-configured-storage-addons.model'; export * from './get-files-response.model'; diff --git a/src/app/shared/models/licenses-json-api.model.ts b/src/app/shared/models/licenses-json-api.model.ts index 356f06d0..6d44a977 100644 --- a/src/app/shared/models/licenses-json-api.model.ts +++ b/src/app/shared/models/licenses-json-api.model.ts @@ -6,6 +6,10 @@ export interface LicensesResponseJsonApi { links: PaginationLinksJsonApi; } +export interface LicenseResponseJsonApi { + data: LicenseDataJsonApi; +} + export type LicenseDataJsonApi = ApiData; export interface LicenseAttributesJsonApi { diff --git a/src/app/shared/services/contributors.service.ts b/src/app/shared/services/contributors.service.ts index 7c7bde8f..80ee919e 100644 --- a/src/app/shared/services/contributors.service.ts +++ b/src/app/shared/services/contributors.service.ts @@ -38,7 +38,7 @@ export class ContributorsService { const baseUrl = this.getBaseUrl(resourceType, resourceId); return this.jsonApiService - .get>(baseUrl) + .get>(`${baseUrl}/`) .pipe(map((response) => ContributorsMapper.fromResponse(response.data))); } diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts index fd33304a..2dfe49cf 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -6,7 +6,13 @@ import { inject, Injectable } from '@angular/core'; import { ApiData, JsonApiResponse } from '@core/models'; import { JsonApiService } from '@core/services'; -import { MapFile, MapFileCustomMetadata, MapFileRevision, MapFiles } from '@osf/features/project/files/mappers'; +import { + MapFile, + MapFileCustomMetadata, + MapFileRevision, + MapFiles, + MapFileVersions, +} from '@osf/features/project/files/mappers'; import { CreateFolderResponse, FileCustomMetadata, @@ -30,8 +36,10 @@ import { GetFileResponse, GetFilesResponse, OsfFile, + OsfFileVersion, } from '@shared/models'; import { ConfiguredStorageAddon } from '@shared/models/addons/configured-storage-addon.model'; +import { FileVersionsResponseJsonApi } from '@shared/models/files/file-version-json-api.model'; import { GetConfiguredStorageAddonsJsonApi } from '@shared/models/files/get-configured-storage-addons.model'; import { ToastService } from '@shared/services/toast.service'; @@ -94,7 +102,7 @@ export class FilesService { } getFolder(link: string): Observable { - return this.#jsonApiService.get>(link).pipe( + return this.#jsonApiService.get(link).pipe( map((response) => MapFile(response.data)), catchError((error) => { this.toastService.showError(error.error.message); @@ -124,7 +132,7 @@ export class FilesService { resource: resourceId, }; - return this.#jsonApiService.post>(link, body).pipe( + return this.#jsonApiService.post(link, body).pipe( map((response) => MapFile(response.data)), catchError((error) => { this.toastService.showError(error.error.message); @@ -146,6 +154,23 @@ export class FilesService { .pipe(map((response) => MapFile(response.data))); } + getFileById(fileGuid: string): Observable { + return this.#jsonApiService + .get(`${environment.apiUrl}/files/${fileGuid}/`) + .pipe(map((response) => MapFile(response.data))); + } + + getFileVersions(fileGuid: string): Observable { + const params = { + sort: '-id', + 'page[size]': 50, + }; + + return this.#jsonApiService + .get(`${environment.apiUrl}/files/${fileGuid}/versions/`, params) + .pipe(map((response) => MapFileVersions(response))); + } + getFileMetadata(fileGuid: string): Observable { return this.#jsonApiService .get(`${environment.apiUrl}/custom_file_metadata_records/${fileGuid}/`) diff --git a/src/app/shared/stores/contributors/contributors.actions.ts b/src/app/shared/stores/contributors/contributors.actions.ts index b769c148..40626b7d 100644 --- a/src/app/shared/stores/contributors/contributors.actions.ts +++ b/src/app/shared/stores/contributors/contributors.actions.ts @@ -70,3 +70,7 @@ export class SearchUsers { export class ClearUsers { static readonly type = '[Contributors] Clear Users'; } + +export class ResetContributorsState { + static readonly type = '[Contributors] Reset State'; +} diff --git a/src/app/shared/stores/contributors/contributors.model.ts b/src/app/shared/stores/contributors/contributors.model.ts index 7e0c466a..88dffcca 100644 --- a/src/app/shared/stores/contributors/contributors.model.ts +++ b/src/app/shared/stores/contributors/contributors.model.ts @@ -11,3 +11,20 @@ export interface ContributorsStateModel { totalCount: number; }; } + +export const DefaultState = { + contributorsList: { + data: [], + isLoading: false, + error: '', + searchValue: null, + permissionFilter: null, + bibliographyFilter: null, + }, + users: { + data: [], + isLoading: false, + error: null, + totalCount: 0, + }, +}; diff --git a/src/app/shared/stores/contributors/contributors.state.ts b/src/app/shared/stores/contributors/contributors.state.ts index f57b60e0..787821c4 100644 --- a/src/app/shared/stores/contributors/contributors.state.ts +++ b/src/app/shared/stores/contributors/contributors.state.ts @@ -11,32 +11,18 @@ import { ClearUsers, DeleteContributor, GetAllContributors, + ResetContributorsState, SearchUsers, UpdateBibliographyFilter, UpdateContributor, UpdatePermissionFilter, UpdateSearchValue, } from './contributors.actions'; -import { ContributorsStateModel } from './contributors.model'; +import { ContributorsStateModel, DefaultState } from './contributors.model'; @State({ name: 'contributors', - defaults: { - contributorsList: { - data: [], - isLoading: false, - error: '', - searchValue: null, - permissionFilter: null, - bibliographyFilter: null, - }, - users: { - data: [], - isLoading: false, - error: null, - totalCount: 0, - }, - }, + defaults: { ...DefaultState }, }) @Injectable() export class ContributorsState { @@ -47,7 +33,7 @@ export class ContributorsState { const state = ctx.getState(); ctx.patchState({ - contributorsList: { ...state.contributorsList, isLoading: true, error: null }, + contributorsList: { ...state.contributorsList, data: [], isLoading: true, error: null }, }); if (!action.resourceId || !action.resourceType) { @@ -207,6 +193,11 @@ export class ContributorsState { ctx.patchState({ users: { data: [], isLoading: false, error: null, totalCount: 0 } }); } + @Action(ResetContributorsState) + resetState(ctx: StateContext) { + ctx.setState({ ...DefaultState }); + } + private handleError(ctx: StateContext, section: 'contributorsList' | 'users', error: Error) { ctx.patchState({ [section]: {