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 18a160459..8a5250e76 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 @@ -1,7 +1,7 @@ - - @if (preprint()) { - @let preprintValue = preprint()!; +@let preprintValue = preprint(); + + @if (preprintValue) {
@if (preprintValue.customPublicationCitation) {
@@ -19,12 +19,17 @@

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

} - @if (preprintValue.doi) { + @if (preprintValue.articleDoiLink) {

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

- - {{ preprint()?.articleDoiLink }} + + {{ preprintValue.articleDoiLink }}
} 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 index 5722bc8e5..e69de29bb 100644 --- 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 @@ -1,3 +0,0 @@ -.white-space-pre-line { - white-space: pre-line; -} 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 index c6c1824d8..4f5bff759 100644 --- 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 @@ -1,11 +1,12 @@ -import { MockComponents, MockPipe } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponents } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component'; -import { InterpolatePipe } from '@osf/shared/pipes/interpolate.pipe'; import { SubjectsSelectors } from '@osf/shared/stores/subjects'; import { CitationSectionComponent } from '../citation-section/citation-section.component'; @@ -13,51 +14,46 @@ import { CitationSectionComponent } from '../citation-section/citation-section.c import { AdditionalInfoComponent } from './additional-info.component'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; describe('AdditionalInfoComponent', () => { let component: AdditionalInfoComponent; let fixture: ComponentFixture; + let store: Store; - const mockPreprint = PREPRINT_MOCK; + interface SetupOverrides extends BaseSetupOverrides { + preprintProviderId?: string; + } - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - AdditionalInfoComponent, - OSFTestingModule, - ...MockComponents(CitationSectionComponent, LicenseDisplayComponent), - MockPipe(InterpolatePipe), - ], + function setup(overrides: SetupOverrides = {}) { + TestBed.configureTestingModule({ + imports: [AdditionalInfoComponent, ...MockComponents(CitationSectionComponent, LicenseDisplayComponent)], providers: [ + provideOSFCore(), provideMockStore({ - signals: [ - { - selector: PreprintSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintSelectors.isPreprintLoading, - value: false, - }, - { - selector: SubjectsSelectors.getSelectedSubjects, - value: [], - }, - { - selector: SubjectsSelectors.areSelectedSubjectsLoading, - value: false, - }, - ], + signals: mergeSignalOverrides( + [ + { selector: PreprintSelectors.getPreprint, value: PREPRINT_MOCK }, + { selector: PreprintSelectors.isPreprintLoading, value: false }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, + { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false }, + ], + overrides.selectorOverrides + ), }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(AdditionalInfoComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('preprintProviderId', 'osf'); + store = TestBed.inject(Store); + fixture.componentRef.setInput('preprintProviderId', overrides.preprintProviderId ?? 'osf'); fixture.detectChanges(); + } + + beforeEach(() => { + setup(); }); it('should create', () => { @@ -66,12 +62,12 @@ describe('AdditionalInfoComponent', () => { it('should return license from preprint when available', () => { const license = component.license(); - expect(license).toBe(mockPreprint.embeddedLicense); + expect(license).toBe(PREPRINT_MOCK.embeddedLicense); }); it('should return license options record from preprint when available', () => { const licenseOptionsRecord = component.licenseOptionsRecord(); - expect(licenseOptionsRecord).toEqual(mockPreprint.licenseOptions); + expect(licenseOptionsRecord).toEqual(PREPRINT_MOCK.licenseOptions); }); it('should have skeleton data array with 5 null elements', () => { @@ -89,4 +85,36 @@ describe('AdditionalInfoComponent', () => { queryParams: { search: 'test-tag' }, }); }); + + it('should not render DOI link when articleDoiLink is missing', () => { + const doiLink = fixture.nativeElement.querySelector('a[href*="doi.org"]'); + expect(doiLink).toBeNull(); + }); + + it('should render DOI link when articleDoiLink is available', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { + ...PREPRINT_MOCK, + articleDoiLink: 'https://doi.org/10.1234/sample.article-doi', + }, + }, + ], + }); + + const doiLink = fixture.nativeElement.querySelector('a[href*="doi.org"]') as HTMLAnchorElement | null; + expect(doiLink).not.toBeNull(); + expect(doiLink?.getAttribute('href')).toBe('https://doi.org/10.1234/sample.article-doi'); + expect(doiLink?.textContent?.trim()).toBe('https://doi.org/10.1234/sample.article-doi'); + }); + + it('should not dispatch subject fetch when preprint id is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: null }], + }); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); }); 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 index fa1253572..689fee6c5 100644 --- 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 @@ -19,41 +19,33 @@ import { CitationSectionComponent } from '../citation-section/citation-section.c @Component({ selector: 'osf-preprint-additional-info', - imports: [Card, TranslatePipe, Tag, Skeleton, DatePipe, CitationSectionComponent, LicenseDisplayComponent], + imports: [Card, Tag, Skeleton, CitationSectionComponent, LicenseDisplayComponent, DatePipe, TranslatePipe], templateUrl: './additional-info.component.html', styleUrl: './additional-info.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AdditionalInfoComponent { - private actions = createDispatchMap({ - fetchSubjects: FetchSelectedSubjects, - }); - private router = inject(Router); + private readonly router = inject(Router); + private readonly actions = createDispatchMap({ fetchSubjects: FetchSelectedSubjects }); - preprintProviderId = input.required(); + readonly preprintProviderId = input.required(); - preprint = select(PreprintSelectors.getPreprint); - isPreprintLoading = select(PreprintSelectors.isPreprintLoading); + readonly preprint = select(PreprintSelectors.getPreprint); + readonly isPreprintLoading = select(PreprintSelectors.isPreprintLoading); - subjects = select(SubjectsSelectors.getSelectedSubjects); - areSelectedSubjectsLoading = select(SubjectsSelectors.areSelectedSubjectsLoading); + readonly subjects = select(SubjectsSelectors.getSelectedSubjects); + readonly areSelectedSubjectsLoading = select(SubjectsSelectors.areSelectedSubjectsLoading); - license = computed(() => { - const preprint = this.preprint(); - if (!preprint) return null; - return preprint.embeddedLicense; - }); + readonly license = computed(() => this.preprint()?.embeddedLicense ?? null); + readonly licenseOptionsRecord = computed(() => (this.preprint()?.licenseOptions ?? {}) as Record); - licenseOptionsRecord = computed(() => (this.preprint()?.licenseOptions ?? {}) as Record); - - skeletonData = Array.from({ length: 5 }, () => null); + readonly skeletonData = new Array(5).fill(null); constructor() { effect(() => { - const preprint = this.preprint(); - if (!preprint) return; - - this.actions.fetchSubjects(this.preprint()!.id, ResourceType.Preprint); + const preprintId = this.preprint()?.id; + if (!preprintId) return; + this.actions.fetchSubjects(preprintId, ResourceType.Preprint); }); } diff --git a/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.html b/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.html index dfce6c59d..96c36d724 100644 --- a/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.html +++ b/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.html @@ -4,6 +4,7 @@

{{ 'project.overview.metadata.citation' | translate }}

+ @if (areCitationsLoading()) { @@ -18,25 +19,28 @@

{{ citation.title }}

+

{{ 'project.overview.metadata.getMoreCitations' | translate }}

+ {{ selectedOption.label }} + @if (styledCitation()) {

{{ styledCitation()?.citation }}

} diff --git a/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.spec.ts b/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.spec.ts index 340def6a0..614674f66 100644 --- a/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.spec.ts @@ -1,104 +1,160 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Store } from '@ngxs/store'; -import { CitationStyle } from '@osf/shared/models/citations/citation-style.model'; -import { CitationsSelectors } from '@osf/shared/stores/citations'; +import { SelectChangeEvent, SelectFilterEvent } from 'primeng/select'; + +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { ResourceType } from '@shared/enums/resource-type.enum'; +import { + CitationsSelectors, + FetchDefaultProviderCitationStyles, + GetCitationStyles, + GetStyledCitation, +} from '@shared/stores/citations'; import { CitationSectionComponent } from './citation-section.component'; import { CITATION_STYLES_MOCK } from '@testing/mocks/citation-style.mock'; -import { TranslationServiceMock } from '@testing/mocks/translation.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; describe('CitationSectionComponent', () => { let component: CitationSectionComponent; let fixture: ComponentFixture; + let store: Store; + + const mockCitationStyles = CITATION_STYLES_MOCK; + const mockDefaultCitations = [{ id: 'apa', title: 'APA', citation: 'APA Citation Text' }]; + const mockStyledCitation = { citation: 'Styled Citation Text' }; - const mockCitationStyles: CitationStyle[] = CITATION_STYLES_MOCK; - const mockDefaultCitations = { - apa: 'APA Citation Text', - mla: 'MLA Citation Text', - }; - const mockStyledCitation = 'Styled Citation Text'; + interface SetupOverrides extends BaseSetupOverrides { + preprintId?: string; + providerId?: string; + detectChanges?: boolean; + } - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CitationSectionComponent, OSFTestingModule], + function setup(overrides: SetupOverrides = {}) { + TestBed.configureTestingModule({ + imports: [CitationSectionComponent], providers: [ - TranslationServiceMock, + provideOSFCore(), provideMockStore({ - signals: [ - { - selector: CitationsSelectors.getDefaultCitations, - value: mockDefaultCitations, - }, - { - selector: CitationsSelectors.getDefaultCitationsLoading, - value: false, - }, - { - selector: CitationsSelectors.getCitationStyles, - value: mockCitationStyles, - }, - { - selector: CitationsSelectors.getCitationStylesLoading, - value: false, - }, - { - selector: CitationsSelectors.getStyledCitation, - value: mockStyledCitation, - }, - ], + signals: mergeSignalOverrides( + [ + { selector: CitationsSelectors.getDefaultCitations, value: mockDefaultCitations }, + { selector: CitationsSelectors.getDefaultCitationsLoading, value: false }, + { selector: CitationsSelectors.getCitationStyles, value: mockCitationStyles }, + { selector: CitationsSelectors.getCitationStylesLoading, value: false }, + { selector: CitationsSelectors.getStyledCitation, value: mockStyledCitation }, + ], + overrides.selectorOverrides + ), }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(CitationSectionComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); - fixture.componentRef.setInput('preprintId', 'test-preprint-id'); - }); + fixture.componentRef.setInput('preprintId', overrides.preprintId ?? 'test-preprint-id'); + fixture.componentRef.setInput('providerId', overrides.providerId ?? 'osf'); + + if (overrides.detectChanges ?? true) { + fixture.detectChanges(); + (store.dispatch as jest.Mock).mockClear(); + } + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should return default citations from store', () => { - const defaultCitations = component.defaultCitations(); - expect(defaultCitations).toBe(mockDefaultCitations); + it('should return signals directly from the store', () => { + setup(); + expect(component.defaultCitations()).toBe(mockDefaultCitations); + expect(component.citationStyles()).toBe(mockCitationStyles); + expect(component.styledCitation()).toEqual(mockStyledCitation); }); - it('should return citation styles from store', () => { - const citationStyles = component.citationStyles(); - expect(citationStyles).toBe(mockCitationStyles); + it('should map citation styles into select options', () => { + setup(); + const citationStylesOptions = component.citationStylesOptions(); + expect(citationStylesOptions).toEqual( + mockCitationStyles.map((style) => ({ + label: style.title, + value: style, + })) + ); }); - it('should return styled citation from store', () => { - const styledCitation = component.styledCitation(); - expect(styledCitation).toBe(mockStyledCitation); + it('should return loading filter message when citation styles are loading', () => { + setup({ + selectorOverrides: [{ selector: CitationsSelectors.getCitationStylesLoading, value: true }], + }); + expect(component.filterMessage()).toBe('project.overview.metadata.citationLoadingPlaceholder'); }); - it('should have citation styles options signal', () => { - const citationStylesOptions = component.citationStylesOptions(); - expect(citationStylesOptions).toBeDefined(); - expect(Array.isArray(citationStylesOptions)).toBe(true); + it('should return empty-state filter message when citation styles are not loading', () => { + setup(); + expect(component.filterMessage()).toBe('project.overview.metadata.noCitationStylesFound'); }); - it('should handle citation style filter search', () => { - const mockEvent = { - originalEvent: new Event('input'), - filter: 'test filter', - }; - - expect(() => component.handleCitationStyleFilterSearch(mockEvent)).not.toThrow(); + it('should dispatch FetchDefaultProviderCitationStyles on init with correct inputs', () => { + setup({ detectChanges: false }); + fixture.detectChanges(); + expect(store.dispatch).toHaveBeenCalledWith( + new FetchDefaultProviderCitationStyles(ResourceType.Preprint, 'test-preprint-id', 'osf') + ); }); - it('should handle get styled citation', () => { - const mockEvent = { + it('should dispatch GetStyledCitation when a style is selected', () => { + setup(); + const mockEvent: SelectChangeEvent = { value: { id: 'style-1' }, originalEvent: new Event('change'), }; - expect(() => component.handleGetStyledCitation(mockEvent)).not.toThrow(); + component.handleGetStyledCitation(mockEvent); + + expect(store.dispatch).toHaveBeenCalledWith( + new GetStyledCitation(ResourceType.Preprint, 'test-preprint-id', 'style-1') + ); }); + + it('should debounce and deduplicate citation style filter dispatches', fakeAsync(() => { + setup(); + const preventDefault = jest.fn(); + const eventApa: SelectFilterEvent = { + originalEvent: { preventDefault } as unknown as Event, + filter: 'apa', + }; + + component.handleCitationStyleFilterSearch(eventApa); + + expect(preventDefault).toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + + tick(299); + expect(store.dispatch).not.toHaveBeenCalled(); + + tick(1); + expect(store.dispatch).toHaveBeenCalledWith(new GetCitationStyles('apa')); + expect(store.dispatch).toHaveBeenCalledTimes(1); + + (store.dispatch as jest.Mock).mockClear(); + component.handleCitationStyleFilterSearch(eventApa); + tick(300); + expect(store.dispatch).not.toHaveBeenCalled(); + + const eventMla: SelectFilterEvent = { + originalEvent: { preventDefault: jest.fn() } as unknown as Event, + filter: 'mla', + }; + component.handleCitationStyleFilterSearch(eventMla); + tick(300); + + expect(store.dispatch).toHaveBeenCalledWith(new GetCitationStyles('mla')); + })); }); diff --git a/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.ts b/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.ts index 4435424fe..899d739d7 100644 --- a/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.ts +++ b/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.ts @@ -30,34 +30,34 @@ import { FetchDefaultProviderCitationStyles, GetCitationStyles, GetStyledCitation, - UpdateCustomCitation, } from '@shared/stores/citations'; @Component({ selector: 'osf-preprint-citation-section', - imports: [Accordion, AccordionPanel, AccordionHeader, TranslatePipe, AccordionContent, Skeleton, Divider, Select], + imports: [Accordion, AccordionPanel, AccordionHeader, AccordionContent, Divider, Skeleton, Select, TranslatePipe], templateUrl: './citation-section.component.html', styleUrl: './citation-section.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class CitationSectionComponent implements OnInit { - preprintId = input.required(); - providerId = input.required(); + readonly preprintId = input.required(); + readonly providerId = input.required(); private readonly destroyRef = inject(DestroyRef); private readonly filterSubject = new Subject(); - private actions = createDispatchMap({ + + private readonly actions = createDispatchMap({ getDefaultCitations: FetchDefaultProviderCitationStyles, getCitationStyles: GetCitationStyles, getStyledCitation: GetStyledCitation, - updateCustomCitation: UpdateCustomCitation, }); - defaultCitations = select(CitationsSelectors.getDefaultCitations); - areCitationsLoading = select(CitationsSelectors.getDefaultCitationsLoading); - citationStyles = select(CitationsSelectors.getCitationStyles); - areCitationStylesLoading = select(CitationsSelectors.getCitationStylesLoading); - styledCitation = select(CitationsSelectors.getStyledCitation); + readonly defaultCitations = select(CitationsSelectors.getDefaultCitations); + readonly areCitationsLoading = select(CitationsSelectors.getDefaultCitationsLoading); + readonly citationStyles = select(CitationsSelectors.getCitationStyles); + readonly areCitationStylesLoading = select(CitationsSelectors.getCitationStylesLoading); + readonly styledCitation = select(CitationsSelectors.getStyledCitation); + citationStylesOptions = signal[]>([]); filterMessage = computed(() => @@ -87,9 +87,7 @@ export class CitationSectionComponent implements OnInit { private setupFilterDebounce(): void { this.filterSubject .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) - .subscribe((filterValue) => { - this.actions.getCitationStyles(filterValue); - }); + .subscribe((filterValue) => this.actions.getCitationStyles(filterValue)); } private setupCitationStylesEffect(): void { @@ -100,6 +98,7 @@ export class CitationSectionComponent implements OnInit { label: style.title, value: style, })); + this.citationStylesOptions.set(options); }); } 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 052197694..e5eedf033 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 @@ -1,7 +1,8 @@ - - @if (preprint()) { - @let preprintValue = preprint()!; +@let preprintValue = preprint(); +@let preprintProviderValue = preprintProvider(); + + @if (preprintValue) {

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

@@ -29,65 +30,8 @@

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

} - @if (preprintProvider()?.assertionsEnabled) { -
-

{{ '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 | fixSpecialChar }} - } - @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 | fixSpecialChar }} - } - @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 }} -

- } - } - } -
+ @if (preprintProviderValue?.assertionsEnabled) { + } @if (preprintValue.nodeId) { @@ -100,12 +44,12 @@

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

} - @if (preprintProvider()?.assertionsEnabled) { + @if (preprintProviderValue?.assertionsEnabled) {

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

@if (preprintValue.hasCoi) { - {{ preprintValue.coiStatement | fixSpecialChar }} + {{ preprintValue.coiStatement }} } @else {

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

} @@ -113,7 +57,7 @@

{{ 'preprints.preprintStepper.review.sections.authorAssertions.conflictOfInt }

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 index 327d980dc..6598d2107 100644 --- 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 @@ -1,5 +1,8 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; +import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -7,10 +10,16 @@ import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; -import { ContributorsSelectors } from '@shared/stores/contributors'; -import { InstitutionsSelectors } from '@shared/stores/institutions'; - +import { ResourceType } from '@shared/enums/resource-type.enum'; +import { + ContributorsSelectors, + GetBibliographicContributors, + LoadMoreBibliographicContributors, + ResetContributorsState, +} from '@shared/stores/contributors'; +import { FetchResourceInstitutions, InstitutionsSelectors } from '@shared/stores/institutions'; + +import { PreprintAuthorAssertionsComponent } from '../preprint-author-assertions/preprint-author-assertions.component'; import { PreprintDoiSectionComponent } from '../preprint-doi-section/preprint-doi-section.component'; import { GeneralInformationComponent } from './general-information.component'; @@ -19,97 +28,133 @@ import { MOCK_CONTRIBUTOR } from '@testing/mocks/contributors.mock'; import { MOCK_INSTITUTION } from '@testing/mocks/institution.mock'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { MockComponentWithSignal } from '@testing/providers/component-provider.mock'; +import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; describe('GeneralInformationComponent', () => { let component: GeneralInformationComponent; let fixture: ComponentFixture; + let store: Store; - const mockPreprint = PREPRINT_MOCK; const mockContributors = [MOCK_CONTRIBUTOR]; const mockInstitutions = [MOCK_INSTITUTION]; - const mockPreprintProvider = PREPRINT_PROVIDER_DETAILS_MOCK; const mockWebUrl = 'https://staging4.osf.io'; - beforeEach(async () => { - await TestBed.configureTestingModule({ + interface SetupOverrides extends BaseSetupOverrides { + platformId?: string; + } + + function setup(overrides: SetupOverrides = {}) { + TestBed.configureTestingModule({ imports: [ GeneralInformationComponent, - OSFTestingModule, ...MockComponents( - TruncatedTextComponent, - PreprintDoiSectionComponent, - IconComponent, AffiliatedInstitutionsViewComponent, - ContributorsListComponent + ContributorsListComponent, + IconComponent, + PreprintDoiSectionComponent, + PreprintAuthorAssertionsComponent ), + MockComponentWithSignal('osf-truncated-text'), ], providers: [ + provideOSFCore(), MockProvider(ENVIRONMENT, { webUrl: mockWebUrl }), + MockProvider(PLATFORM_ID, overrides.platformId ?? 'browser'), provideMockStore({ - signals: [ - { - selector: PreprintSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintSelectors.isPreprintLoading, - value: false, - }, - { - selector: ContributorsSelectors.getBibliographicContributors, - value: mockContributors, - }, - { - selector: ContributorsSelectors.isBibliographicContributorsLoading, - value: false, - }, - { - selector: ContributorsSelectors.hasMoreBibliographicContributors, - value: false, - }, - { - selector: InstitutionsSelectors.getResourceInstitutions, - value: mockInstitutions, - }, - ], + signals: mergeSignalOverrides( + [ + { selector: PreprintSelectors.getPreprint, value: PREPRINT_MOCK }, + { selector: PreprintSelectors.isPreprintLoading, value: false }, + { selector: ContributorsSelectors.getBibliographicContributors, value: mockContributors }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, + { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false }, + { selector: InstitutionsSelectors.getResourceInstitutions, value: mockInstitutions }, + ], + overrides.selectorOverrides + ), }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(GeneralInformationComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); + fixture.componentRef.setInput('preprintProvider', PREPRINT_PROVIDER_DETAILS_MOCK); + } - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); + it('should create', () => { + setup(); + expect(component).toBeTruthy(); }); - it('should return preprint from store', () => { - const preprint = component.preprint(); - expect(preprint).toBe(mockPreprint); + it('should expose preprint, contributors, institutions and computed link', () => { + setup(); + expect(component.preprint()).toBe(PREPRINT_MOCK); + expect(component.bibliographicContributors()).toBe(mockContributors); + expect(component.affiliatedInstitutions()).toBe(mockInstitutions); + expect(component.nodeLink()).toBe(`${mockWebUrl}/node-123`); + expect(component.preprintProvider()).toBe(PREPRINT_PROVIDER_DETAILS_MOCK); }); - it('should return contributors from store', () => { - const contributors = component.bibliographicContributors(); - expect(contributors).toBe(mockContributors); + it('should have skeleton data array with 5 null elements', () => { + setup(); + expect(component.skeletonData).toHaveLength(5); + expect(component.skeletonData.every((item) => item === null)).toBe(true); }); - it('should return affiliated institutions from store', () => { - const institutions = component.affiliatedInstitutions(); - expect(institutions).toBe(mockInstitutions); + it('should dispatch constructor effect actions when preprint id exists', () => { + setup(); + fixture.detectChanges(); + TestBed.flushEffects(); + expect(store.dispatch).toHaveBeenCalledWith( + new GetBibliographicContributors(PREPRINT_MOCK.id, ResourceType.Preprint) + ); + expect(store.dispatch).toHaveBeenCalledWith(new FetchResourceInstitutions(PREPRINT_MOCK.id, ResourceType.Preprint)); }); - it('should compute node link from preprint', () => { - const nodeLink = component.nodeLink(); - expect(nodeLink).toBe(`${mockWebUrl}/node-123`); + it('should not dispatch constructor effect actions when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: undefined }], + }); + (store.dispatch as jest.Mock).mockClear(); + fixture.detectChanges(); + TestBed.flushEffects(); + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should have skeleton data array with 5 null elements', () => { - expect(component.skeletonData).toHaveLength(5); - expect(component.skeletonData.every((item) => item === null)).toBe(true); + it('should dispatch load more contributors with preprint id', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + component.handleLoadMoreContributors(); + expect(store.dispatch).toHaveBeenCalledWith( + new LoadMoreBibliographicContributors(PREPRINT_MOCK.id, ResourceType.Preprint) + ); + }); + + it('should dispatch load more contributors with undefined id when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: undefined }], + }); + (store.dispatch as jest.Mock).mockClear(); + component.handleLoadMoreContributors(); + expect(store.dispatch).toHaveBeenCalledWith( + new LoadMoreBibliographicContributors(undefined, ResourceType.Preprint) + ); + }); + + it('should reset contributors state on destroy in browser', () => { + setup({ platformId: 'browser' }); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(new ResetContributorsState()); }); - it('should have preprint provider input', () => { - expect(component.preprintProvider()).toBe(mockPreprintProvider); + it('should not reset contributors state on destroy in server platform', () => { + setup({ platformId: 'server' }); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).not.toHaveBeenCalledWith(new ResetContributorsState()); }); }); 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 8228a9cd1..85a1fe398 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 @@ -17,18 +17,15 @@ import { output, PLATFORM_ID, } from '@angular/core'; -import { FormsModule } from '@angular/forms'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums'; import { PreprintProviderDetails } from '@osf/features/preprints/models'; -import { FetchPreprintDetails, PreprintSelectors } from '@osf/features/preprints/store/preprint'; +import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe'; import { ContributorsSelectors, GetBibliographicContributors, @@ -37,21 +34,21 @@ import { } from '@osf/shared/stores/contributors'; import { FetchResourceInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions'; +import { PreprintAuthorAssertionsComponent } from '../preprint-author-assertions/preprint-author-assertions.component'; import { PreprintDoiSectionComponent } from '../preprint-doi-section/preprint-doi-section.component'; @Component({ selector: 'osf-preprint-general-information', imports: [ Card, - TranslatePipe, Skeleton, - FormsModule, - TruncatedTextComponent, - PreprintDoiSectionComponent, - IconComponent, AffiliatedInstitutionsViewComponent, ContributorsListComponent, - FixSpecialCharPipe, + IconComponent, + PreprintDoiSectionComponent, + PreprintAuthorAssertionsComponent, + TruncatedTextComponent, + TranslatePipe, ], templateUrl: './general-information.component.html', styleUrl: './general-information.component.scss', @@ -62,40 +59,36 @@ export class GeneralInformationComponent implements OnDestroy { private readonly platformId = inject(PLATFORM_ID); private readonly isBrowser = isPlatformBrowser(this.platformId); - readonly ApplicabilityStatus = ApplicabilityStatus; - readonly PreregLinkInfo = PreregLinkInfo; + readonly preprintProvider = input.required(); + readonly preprintVersionSelected = output(); - private actions = createDispatchMap({ + private readonly actions = createDispatchMap({ getBibliographicContributors: GetBibliographicContributors, - resetContributorsState: ResetContributorsState, - fetchPreprintById: FetchPreprintDetails, fetchResourceInstitutions: FetchResourceInstitutions, loadMoreBibliographicContributors: LoadMoreBibliographicContributors, + resetContributorsState: ResetContributorsState, }); - preprintProvider = input.required(); - preprintVersionSelected = output(); - - preprint = select(PreprintSelectors.getPreprint); - isPreprintLoading = select(PreprintSelectors.isPreprintLoading); + readonly preprint = select(PreprintSelectors.getPreprint); + readonly isPreprintLoading = select(PreprintSelectors.isPreprintLoading); - affiliatedInstitutions = select(InstitutionsSelectors.getResourceInstitutions); + readonly affiliatedInstitutions = select(InstitutionsSelectors.getResourceInstitutions); - bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); - areContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); - hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); + readonly bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); + readonly areContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); + readonly hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); - skeletonData = Array.from({ length: 5 }, () => null); + readonly skeletonData = new Array(5).fill(null); - nodeLink = computed(() => `${this.environment.webUrl}/${this.preprint()?.nodeId}`); + readonly nodeLink = computed(() => `${this.environment.webUrl}/${this.preprint()?.nodeId}`); constructor() { effect(() => { - const preprint = this.preprint(); - if (!preprint) return; + const preprintId = this.preprint()?.id; + if (!preprintId) return; - this.actions.getBibliographicContributors(this.preprint()!.id, ResourceType.Preprint); - this.actions.fetchResourceInstitutions(this.preprint()!.id, ResourceType.Preprint); + this.actions.getBibliographicContributors(preprintId, ResourceType.Preprint); + this.actions.fetchResourceInstitutions(preprintId, ResourceType.Preprint); }); } 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 index 4d110f54a..737811593 100644 --- 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 @@ -13,14 +13,22 @@

} @else {

- {{ actionCreatorName() }} + @if (actionCreatorLink()) { + {{ actionCreatorName() }} + } @else { + {{ actionCreatorName() }} + } {{ recentActivityLanguage() | translate: { documentType: documentType()?.singular } }} {{ labelDate() | date: 'MMM d, y' }}

} @if (isPendingWithdrawal()) { - {{ withdrawalRequesterName() }} + @if (withdrawalRequesterLink()) { + {{ withdrawalRequesterName() }} + } @else { + {{ withdrawalRequesterName() }} + } {{ requestActivityLanguage()! | translate: { documentType: documentType()?.singular } }} {{ latestWithdrawalRequest()?.dateLastTransitioned | date: 'MMM d, y' }} } 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 index b8413c5aa..063906282 100644 --- 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 @@ -5,192 +5,168 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ReviewAction } from '@osf/features/moderation/models'; import { ProviderReviewsWorkflow, ReviewsState } from '@osf/features/preprints/enums'; -import { PreprintRequest } from '@osf/features/preprints/models'; +import { PreprintProviderDetails, PreprintRequest } from '@osf/features/preprints/models'; import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { ModerationStatusBannerComponent } from './moderation-status-banner.component'; -import { EnvironmentTokenMock } from '@testing/mocks/environment.token.mock'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; +import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; import { PREPRINT_REQUEST_MOCK } from '@testing/mocks/preprint-request.mock'; -import { MOCK_PROVIDER } from '@testing/mocks/provider.mock'; import { REVIEW_ACTION_MOCK } from '@testing/mocks/review-action.mock'; -import { TranslationServiceMock } from '@testing/mocks/translation.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; describe('ModerationStatusBannerComponent', () => { let component: ModerationStatusBannerComponent; let fixture: ComponentFixture; const mockPreprint = PREPRINT_MOCK; - const mockProvider = MOCK_PROVIDER; - const mockReviewAction: ReviewAction = REVIEW_ACTION_MOCK; - const mockWithdrawalRequest: PreprintRequest = PREPRINT_REQUEST_MOCK; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - ModerationStatusBannerComponent, - OSFTestingModule, - MockComponent(IconComponent), - MockPipes(TitleCasePipe, DatePipe), - ], + const mockProvider = PREPRINT_PROVIDER_DETAILS_MOCK; + const mockReviewAction = REVIEW_ACTION_MOCK; + const mockWithdrawalRequest = PREPRINT_REQUEST_MOCK; + + interface SetupOverrides extends BaseSetupOverrides { + provider?: PreprintProviderDetails | undefined; + latestAction?: ReviewAction | null; + latestWithdrawalRequest?: PreprintRequest | null; + isPendingWithdrawal?: boolean; + } + + function setup(overrides: SetupOverrides = {}) { + TestBed.configureTestingModule({ + imports: [ModerationStatusBannerComponent, MockComponent(IconComponent), ...MockPipes(TitleCasePipe, DatePipe)], providers: [ - TranslationServiceMock, - EnvironmentTokenMock, + provideOSFCore(), provideMockStore({ - signals: [ - { - selector: PreprintSelectors.getPreprint, - value: mockPreprint, - }, - ], + signals: mergeSignalOverrides( + [{ selector: PreprintSelectors.getPreprint, value: mockPreprint }], + overrides.selectorOverrides + ), }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(ModerationStatusBannerComponent); component = fixture.componentInstance; - - fixture.componentRef.setInput('provider', mockProvider); - fixture.componentRef.setInput('latestAction', mockReviewAction); - fixture.componentRef.setInput('latestWithdrawalRequest', mockWithdrawalRequest); - fixture.componentRef.setInput('isPendingWithdrawal', false); - }); + fixture.componentRef.setInput('provider', 'provider' in overrides ? overrides.provider : mockProvider); + fixture.componentRef.setInput( + 'latestAction', + 'latestAction' in overrides ? overrides.latestAction : mockReviewAction + ); + fixture.componentRef.setInput( + 'latestWithdrawalRequest', + 'latestWithdrawalRequest' in overrides ? overrides.latestWithdrawalRequest : mockWithdrawalRequest + ); + fixture.componentRef.setInput('isPendingWithdrawal', overrides.isPendingWithdrawal ?? false); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should return preprint from store', () => { - const preprint = component.preprint(); - expect(preprint).toBe(mockPreprint); - }); - - it('should compute noActions when latestAction is null', () => { - fixture.componentRef.setInput('latestAction', null); - const noActions = component.noActions(); - expect(noActions).toBe(true); - }); - - it('should compute noActions when latestAction exists', () => { - const noActions = component.noActions(); - expect(noActions).toBe(false); - }); - - it('should compute documentType from provider', () => { - const documentType = component.documentType(); - expect(documentType).toBeDefined(); - expect(documentType?.singular).toBeDefined(); - }); - - it('should compute labelDate from preprint dateLastTransitioned', () => { - const labelDate = component.labelDate(); - expect(labelDate).toBe(mockPreprint.dateLastTransitioned); - }); - - it('should compute status for pending preprint', () => { - const status = component.status(); - expect(status).toBe('preprints.details.statusBanner.pending'); - }); - - it('should compute status for accepted preprint', () => { - const acceptedPreprint = { ...mockPreprint, reviewsState: ReviewsState.Accepted }; - jest.spyOn(component, 'preprint').mockReturnValue(acceptedPreprint); - const status = component.status(); - expect(status).toBe('preprints.details.statusBanner.accepted'); + it('should expose store preprint and provider-based document type', () => { + setup(); + expect(component.preprint()).toBe(mockPreprint); + expect(component.documentType()?.singular).toBeDefined(); }); - it('should compute status for pending withdrawal', () => { - fixture.componentRef.setInput('isPendingWithdrawal', true); - const status = component.status(); - expect(status).toBe('preprints.details.statusBanner.pending'); + it('should return null documentType when provider is missing', () => { + setup({ provider: undefined }); + expect(component.documentType()).toBeNull(); }); - it('should compute iconClass for pending preprint', () => { - const iconClass = component.iconClass(); - expect(iconClass).toBe('hourglass'); + it('should compute currentState and fallback to pending when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: undefined }], + }); + expect(component.currentState()).toBe(ReviewsState.Pending); }); - it('should compute iconClass for accepted preprint', () => { - const acceptedPreprint = { ...mockPreprint, reviewsState: ReviewsState.Accepted }; - jest.spyOn(component, 'preprint').mockReturnValue(acceptedPreprint); - const iconClass = component.iconClass(); - expect(iconClass).toBe('check-circle'); - }); - - it('should compute iconClass for pending withdrawal', () => { - fixture.componentRef.setInput('isPendingWithdrawal', true); - const iconClass = component.iconClass(); - expect(iconClass).toBe('hourglass'); - }); + it('should compute labelDate using dateLastTransitioned and prefer dateWithdrawn when present', () => { + setup(); + expect(component.labelDate()).toBe(mockPreprint.dateLastTransitioned); - it('should compute severity for pending preprint with post-moderation', () => { - const postModerationProvider = { ...mockProvider, reviewsWorkflow: ProviderReviewsWorkflow.PostModeration }; - fixture.componentRef.setInput('provider', postModerationProvider); - const severity = component.severity(); - expect(severity).toBe('secondary'); + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { ...mockPreprint, dateWithdrawn: '2024-01-01T00:00:00Z' }, + }, + ], + }); + expect(component.labelDate()).toBe('2024-01-01T00:00:00Z'); }); - it('should compute severity for accepted preprint', () => { - const acceptedPreprint = { ...mockPreprint, reviewsState: ReviewsState.Accepted }; - jest.spyOn(component, 'preprint').mockReturnValue(acceptedPreprint); - const severity = component.severity(); - expect(severity).toBe('success'); + it('should compute status, icon and severity for pending withdrawal', () => { + setup({ isPendingWithdrawal: true }); + expect(component.status()).toBe('preprints.details.statusBanner.pending'); + expect(component.iconClass()).toBe('hourglass'); + expect(component.severity()).toBe('warn'); }); - it('should compute severity for pending withdrawal', () => { - fixture.componentRef.setInput('isPendingWithdrawal', true); - const severity = component.severity(); - expect(severity).toBe('warn'); + it('should compute status, icon and severity from non-pending current state', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { ...mockPreprint, reviewsState: ReviewsState.Accepted }, + }, + ], + }); + expect(component.status()).toBe('preprints.details.statusBanner.accepted'); + expect(component.iconClass()).toBe('check-circle'); + expect(component.severity()).toBe('success'); }); - it('should compute recentActivityLanguage for no actions', () => { - fixture.componentRef.setInput('latestAction', null); - const language = component.recentActivityLanguage(); - expect(language).toBe('preprints.details.moderationStatusBanner.recentActivity.automatic.pending'); + it('should compute severity for pending preprint based on provider workflow', () => { + setup({ + provider: { ...mockProvider, reviewsWorkflow: ProviderReviewsWorkflow.PostModeration }, + }); + expect(component.severity()).toBe('secondary'); }); - it('should compute recentActivityLanguage with actions', () => { - const language = component.recentActivityLanguage(); - expect(language).toBe('preprints.details.moderationStatusBanner.recentActivity.pending'); - }); + it('should compute recent activity language for automatic and action-based paths', () => { + setup({ latestAction: null }); + expect(component.noActions()).toBe(true); + expect(component.recentActivityLanguage()).toBe( + 'preprints.details.moderationStatusBanner.recentActivity.automatic.pending' + ); - it('should compute requestActivityLanguage for pending withdrawal', () => { - fixture.componentRef.setInput('isPendingWithdrawal', true); - const language = component.requestActivityLanguage(); - expect(language).toBe('preprints.details.moderationStatusBanner.recentActivity.pendingWithdrawal'); + setup(); + expect(component.noActions()).toBe(false); + expect(component.recentActivityLanguage()).toBe('preprints.details.moderationStatusBanner.recentActivity.pending'); }); - it('should not compute requestActivityLanguage when not pending withdrawal', () => { - const language = component.requestActivityLanguage(); - expect(language).toBeUndefined(); - }); + it('should compute request activity language only for pending withdrawal', () => { + setup(); + expect(component.requestActivityLanguage()).toBeUndefined(); - it('should compute actionCreatorName from latestAction', () => { - const name = component.actionCreatorName(); - expect(name).toBe('Test User'); + setup({ isPendingWithdrawal: true }); + expect(component.requestActivityLanguage()).toBe( + 'preprints.details.moderationStatusBanner.recentActivity.pendingWithdrawal' + ); }); - it('should compute actionCreatorId from latestAction', () => { - const id = component.actionCreatorId(); - expect(id).toBe('user-1'); - }); + it('should compute action creator fields and nullable action creator link', () => { + setup(); + expect(component.actionCreatorName()).toBe('Test User'); + expect(component.actionCreatorId()).toBe('user-1'); + expect(component.actionCreatorLink()).toBe(`${component.webUrl}/user-1`); - it('should compute actionCreatorLink with environment webUrl', () => { - const link = component.actionCreatorLink(); - expect(link).toBe(`${EnvironmentTokenMock.useValue.webUrl}/user-1`); + setup({ latestAction: null }); + expect(component.actionCreatorLink()).toBeNull(); }); - it('should compute withdrawalRequesterName from latestWithdrawalRequest', () => { - const name = component.withdrawalRequesterName(); - expect(name).toBe('John Doe'); - }); + it('should compute withdrawal requester fields and nullable requester link', () => { + setup(); + expect(component.withdrawalRequesterName()).toBe('John Doe'); + expect(component.withdrawalRequesterId()).toBe('user-123'); + expect(component.withdrawalRequesterLink()).toBe(`${component.webUrl}/user-123`); - it('should compute withdrawalRequesterId from latestWithdrawalRequest', () => { - const id = component.withdrawalRequesterId(); - expect(id).toBe('user-123'); + setup({ latestWithdrawalRequest: null }); + expect(component.withdrawalRequesterLink()).toBeNull(); }); }); 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 index 0440d25a5..00f902413 100644 --- 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 @@ -16,7 +16,7 @@ import { statusSeverityByState, statusSeverityByWorkflow, } from '@osf/features/preprints/constants'; -import { ProviderReviewsWorkflow, ReviewsState } from '@osf/features/preprints/enums'; +import { 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'; @@ -33,16 +33,17 @@ export class ModerationStatusBannerComponent { private readonly translateService = inject(TranslateService); private readonly environment = inject(ENVIRONMENT); - webUrl = this.environment.webUrl; + readonly webUrl = this.environment.webUrl; - preprint = select(PreprintSelectors.getPreprint); - provider = input.required(); - latestAction = input.required(); - latestWithdrawalRequest = input.required(); + readonly preprint = select(PreprintSelectors.getPreprint); - isPendingWithdrawal = input.required(); + readonly provider = input.required(); + readonly latestAction = input.required(); + readonly latestWithdrawalRequest = input.required(); + readonly isPendingWithdrawal = input.required(); noActions = computed(() => this.latestAction() === null); + currentState = computed(() => this.preprint()?.reviewsState ?? ReviewsState.Pending); documentType = computed(() => { const provider = this.provider(); @@ -52,12 +53,12 @@ export class ModerationStatusBannerComponent { }); labelDate = computed(() => { - const preprint = this.preprint()!; - return preprint.dateWithdrawn ? preprint.dateWithdrawn : preprint.dateLastTransitioned; + const preprint = this.preprint(); + return preprint?.dateWithdrawn ? preprint.dateWithdrawn : preprint?.dateLastTransitioned; }); status = computed(() => { - const currentState = this.preprint()!.reviewsState; + const currentState = this.currentState(); if (this.isPendingWithdrawal()) { return statusLabelKeyByState[ReviewsState.Pending]!; @@ -67,7 +68,7 @@ export class ModerationStatusBannerComponent { }); iconClass = computed(() => { - const currentState = this.preprint()!.reviewsState; + const currentState = this.currentState(); if (this.isPendingWithdrawal()) { return statusIconByState[ReviewsState.Pending]; @@ -77,19 +78,22 @@ export class ModerationStatusBannerComponent { }); severity = computed(() => { - const currentState = this.preprint()!.reviewsState; + const currentState = this.currentState(); + const workflow = this.provider()?.reviewsWorkflow; if (this.isPendingWithdrawal()) { return statusSeverityByState[ReviewsState.Pending]; - } else { - return currentState === ReviewsState.Pending - ? statusSeverityByWorkflow[this.provider()?.reviewsWorkflow as ProviderReviewsWorkflow] - : statusSeverityByState[currentState]; } + + if (currentState === ReviewsState.Pending && workflow) { + return statusSeverityByWorkflow[workflow]; + } + + return statusSeverityByState[currentState]; }); recentActivityLanguage = computed(() => { - const currentState = this.preprint()!.reviewsState; + const currentState = this.currentState(); if (this.noActions()) { return recentActivityMessageByState.automatic[currentState]!; @@ -107,8 +111,16 @@ export class ModerationStatusBannerComponent { }); actionCreatorName = computed(() => this.latestAction()?.creator?.name); - actionCreatorLink = computed(() => `${this.webUrl}/${this.actionCreatorId()}`); actionCreatorId = computed(() => this.latestAction()?.creator?.id); + actionCreatorLink = computed(() => { + const creatorId = this.actionCreatorId(); + return creatorId ? `${this.webUrl}/${creatorId}` : null; + }); + withdrawalRequesterName = computed(() => this.latestWithdrawalRequest()?.creator.name); withdrawalRequesterId = computed(() => this.latestWithdrawalRequest()?.creator.id); + withdrawalRequesterLink = computed(() => { + const requesterId = this.withdrawalRequesterId(); + return requesterId ? `${this.webUrl}/${requesterId}` : null; + }); } diff --git a/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.html b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.html new file mode 100644 index 000000000..4260565a6 --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.html @@ -0,0 +1,69 @@ +
+
+

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

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

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

+ } + @case (ApplicabilityStatus.Unavailable) { +

{{ preprint().whyNoData }}

+ } + @case (ApplicabilityStatus.Applicable) { + @for (link of preprint().dataLinks; track link) { +

{{ link }}

+ } + } + } +
+ +
+

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

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

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

+ } + @case (ApplicabilityStatus.Unavailable) { +

{{ preprint().whyNoPrereg }}

+ } + @case (ApplicabilityStatus.Applicable) { + @switch (preprint().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 preprint().preregLinks; track $index) { + {{ link }} + } + } + } +
+ +
+

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

+ + @if (preprint().hasCoi) { +

{{ preprint().coiStatement }}

+ } @else { +

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

+ } +
+
diff --git a/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.scss b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.spec.ts new file mode 100644 index 000000000..5bb0513e0 --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.spec.ts @@ -0,0 +1,43 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums'; + +import { PreprintAuthorAssertionsComponent } from './preprint-author-assertions.component'; + +import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; + +describe('PreprintAuthorAssertionsComponent', () => { + let component: PreprintAuthorAssertionsComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [PreprintAuthorAssertionsComponent], + providers: [provideOSFCore()], + }); + + fixture = TestBed.createComponent(PreprintAuthorAssertionsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('preprint', { ...PREPRINT_MOCK }); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should expose enums to the template', () => { + expect(component.ApplicabilityStatus).toBe(ApplicabilityStatus); + expect(component.PreregLinkInfo).toBe(PreregLinkInfo); + }); + + it('should reactively update when the preprint input changes', () => { + expect(component.preprint()).toEqual(PREPRINT_MOCK); + + const updatedMock = { ...PREPRINT_MOCK, id: 'new-id-999' }; + fixture.componentRef.setInput('preprint', updatedMock); + + expect(component.preprint()).toEqual(updatedMock); + }); +}); diff --git a/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.ts b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.ts new file mode 100644 index 000000000..aff0adf2e --- /dev/null +++ b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.ts @@ -0,0 +1,20 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums'; +import { PreprintModel } from '@osf/features/preprints/models'; + +@Component({ + selector: 'osf-preprint-author-assertions', + imports: [TranslatePipe], + templateUrl: './preprint-author-assertions.component.html', + styleUrl: './preprint-author-assertions.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PreprintAuthorAssertionsComponent { + readonly preprint = input.required(); + + readonly ApplicabilityStatus = ApplicabilityStatus; + readonly PreregLinkInfo = PreregLinkInfo; +} diff --git a/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.html b/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.html index b32a5e842..ba6a67499 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.html +++ b/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.html @@ -26,7 +26,7 @@

{{ 'preprints.details.doi.title' | translate: { documentType: preprintProvid } @else { @if (!preprintValue?.isPublic) {

{{ 'preprints.details.doi.pendingDoi' | translate: { documentType: preprintProviderValue.preprintWord } }}

- } @else if (preprintProvider()?.reviewsWorkflow && !preprintValue?.isPublished) { + } @else if (preprintProviderValue?.reviewsWorkflow && !preprintValue?.isPublished) {

{{ 'preprints.details.doi.pendingDoiModeration' | translate }}

} @else {

{{ 'preprints.details.doi.noDoi' | translate }}

diff --git a/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.spec.ts index e4a95c947..20e2128c2 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.spec.ts @@ -1,48 +1,38 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/models'; +import { PreprintModel } from '@osf/features/preprints/models'; import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { PreprintDoiSectionComponent } from './preprint-doi-section.component'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; -import { TranslationServiceMock } from '@testing/mocks/translation.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('PreprintDoiSectionComponent', () => { let component: PreprintDoiSectionComponent; let fixture: ComponentFixture; - const mockPreprint: PreprintModel = PREPRINT_MOCK; + const mockPreprint = PREPRINT_MOCK; - const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; + const mockProvider = PREPRINT_PROVIDER_DETAILS_MOCK; const mockVersionIds = ['version-1', 'version-2', 'version-3']; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PreprintDoiSectionComponent, OSFTestingModule], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [PreprintDoiSectionComponent], providers: [ - TranslationServiceMock, + provideOSFCore(), provideMockStore({ signals: [ - { - selector: PreprintSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintSelectors.getPreprintVersionIds, - value: mockVersionIds, - }, - { - selector: PreprintSelectors.arePreprintVersionIdsLoading, - value: false, - }, + { selector: PreprintSelectors.getPreprint, value: mockPreprint }, + { selector: PreprintSelectors.getPreprintVersionIds, value: mockVersionIds }, + { selector: PreprintSelectors.arePreprintVersionIdsLoading, value: false }, ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(PreprintDoiSectionComponent); component = fixture.componentInstance; @@ -52,11 +42,9 @@ describe('PreprintDoiSectionComponent', () => { it('should compute versions dropdown options from version IDs', () => { const options = component.versionsDropdownOptions(); - expect(options).toEqual([ - { label: 'Version 3', value: 'version-1' }, - { label: 'Version 2', value: 'version-2' }, - { label: 'Version 1', value: 'version-3' }, - ]); + expect(options).toHaveLength(3); + expect(options.map((option) => option.value)).toEqual(['version-1', 'version-2', 'version-3']); + expect(options.every((option) => typeof option.label === 'string' && option.label.length > 0)).toBe(true); }); it('should return empty array when no version IDs', () => { @@ -65,6 +53,12 @@ describe('PreprintDoiSectionComponent', () => { expect(options).toEqual([]); }); + it('should return empty array when version IDs are undefined', () => { + jest.spyOn(component, 'preprintVersionIds').mockReturnValue(undefined as unknown as string[]); + const options = component.versionsDropdownOptions(); + expect(options).toEqual([]); + }); + it('should emit preprintVersionSelected when selecting different version', () => { const emitSpy = jest.spyOn(component.preprintVersionSelected, 'emit'); component.selectPreprintVersion('version-2'); @@ -77,6 +71,13 @@ describe('PreprintDoiSectionComponent', () => { expect(emitSpy).not.toHaveBeenCalled(); }); + it('should not emit when current preprint is unavailable', () => { + jest.spyOn(component, 'preprint').mockReturnValue(undefined as unknown as PreprintModel); + const emitSpy = jest.spyOn(component.preprintVersionSelected, 'emit'); + component.selectPreprintVersion('version-2'); + expect(emitSpy).not.toHaveBeenCalled(); + }); + it('should handle preprint provider input', () => { const provider = component.preprintProvider(); expect(provider).toBe(mockProvider); 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 869822f1f..63df8cc1b 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,10 +1,10 @@ import { select } from '@ngxs/store'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Select } from 'primeng/select'; -import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { PreprintProviderDetails } from '@osf/features/preprints/models'; @@ -18,26 +18,32 @@ import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintDoiSectionComponent { - preprintProvider = input.required(); - preprint = select(PreprintSelectors.getPreprint); + private readonly translateService = inject(TranslateService); - preprintVersionSelected = output(); + readonly preprintProvider = input.required(); - preprintVersionIds = select(PreprintSelectors.getPreprintVersionIds); - arePreprintVersionIdsLoading = select(PreprintSelectors.arePreprintVersionIdsLoading); + readonly preprintVersionSelected = output(); + + readonly preprint = select(PreprintSelectors.getPreprint); + readonly preprintVersionIds = select(PreprintSelectors.getPreprintVersionIds); + readonly arePreprintVersionIdsLoading = select(PreprintSelectors.arePreprintVersionIdsLoading); versionsDropdownOptions = computed(() => { - const preprintVersionIds = this.preprintVersionIds(); + const preprintVersionIds = this.preprintVersionIds() ?? []; if (!preprintVersionIds.length) return []; return preprintVersionIds.map((versionId, index) => ({ - label: `Version ${preprintVersionIds.length - index}`, + label: this.translateService.instant('preprints.details.file.version', { + version: preprintVersionIds.length - index, + }), value: versionId, })); }); selectPreprintVersion(versionId: string) { - if (this.preprint()!.id === versionId) return; + const currentPreprintId = this.preprint()?.id; + + if (!currentPreprintId || currentPreprintId === versionId) return; this.preprintVersionSelected.emit(versionId); } 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 index 3e17a370b..a9858153d 100644 --- 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 @@ -1,8 +1,10 @@ +@let safeLinkValue = safeLink(); +
- @if (safeLink()) { + @if (safeLinkValue) { } + @if (isIframeLoading || isFileLoading()) { } @@ -29,7 +32,7 @@