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) {
}
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 @@
@@ -40,9 +43,8 @@
}
- @if (file()) {
- @let preprintValue = preprint()!;
-
+ @let preprintValue = preprint();
+ @if (file() && preprintValue) {
{{ dateLabel() | translate }}: {{ preprintValue.dateCreated | date: 'longDate' }}
@if (isMedium() || isLarge()) {
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
index e251447a3..04fc5152f 100644
--- 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
@@ -1,6 +1,6 @@
import { MockComponent, MockProvider } from 'ng-mocks';
-import { BehaviorSubject, of } from 'rxjs';
+import { BehaviorSubject } from 'rxjs';
import { ComponentFixture, TestBed } from '@angular/core/testing';
@@ -8,19 +8,20 @@ import { ProviderReviewsWorkflow } from '@osf/features/preprints/enums';
import { PreprintSelectors } from '@osf/features/preprints/store/preprint';
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
import { IS_LARGE, IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens';
+import { FileVersionModel } from '@shared/models/files/file-version.model';
import { DataciteService } from '@shared/services/datacite/datacite.service';
import { PreprintFileSectionComponent } from './preprint-file-section.component';
import { PREPRINT_MOCK } from '@testing/mocks/preprint.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 { DataciteServiceMockBuilder, DataciteServiceMockType } from '@testing/providers/datacite.service.mock';
+import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
describe('PreprintFileSectionComponent', () => {
let component: PreprintFileSectionComponent;
let fixture: ComponentFixture
;
- let dataciteService: jest.Mocked;
+ let dataciteService: DataciteServiceMockType;
let isMediumSubject: BehaviorSubject;
let isLargeSubject: BehaviorSubject;
@@ -32,141 +33,134 @@ describe('PreprintFileSectionComponent', () => {
render: 'https://example.com/render',
},
};
- const mockFileVersions = [
+ const mockFileVersions: FileVersionModel[] = [
{
id: '1',
- dateCreated: '2024-01-15T10:00:00Z',
+ size: 100,
+ name: 'test-file-v1.pdf',
+ dateCreated: new Date('2024-01-15T10:00:00Z'),
downloadLink: 'https://example.com/download/1',
},
{
id: '2',
- dateCreated: '2024-01-16T10:00:00Z',
+ size: 200,
+ name: 'test-file-v2.pdf',
+ dateCreated: new Date('2024-01-16T10:00:00Z'),
downloadLink: 'https://example.com/download/2',
},
];
- beforeEach(async () => {
+ interface SetupOverrides extends BaseSetupOverrides {
+ providerReviewsWorkflow?: ProviderReviewsWorkflow | null;
+ }
+
+ function setup(overrides: SetupOverrides = {}) {
isMediumSubject = new BehaviorSubject(false);
isLargeSubject = new BehaviorSubject(true);
+ dataciteService = DataciteServiceMockBuilder.create().build();
- await TestBed.configureTestingModule({
- imports: [PreprintFileSectionComponent, OSFTestingModule, MockComponent(LoadingSpinnerComponent)],
+ TestBed.configureTestingModule({
+ imports: [PreprintFileSectionComponent, MockComponent(LoadingSpinnerComponent)],
providers: [
- TranslationServiceMock,
- {
- provide: DataciteService,
- useValue: {
- logIdentifiableDownload: jest.fn().mockReturnValue(of(void 0)),
- },
- },
+ provideOSFCore(),
+ MockProvider(DataciteService, dataciteService),
MockProvider(IS_MEDIUM, isMediumSubject),
MockProvider(IS_LARGE, isLargeSubject),
provideMockStore({
- signals: [
- {
- selector: PreprintSelectors.getPreprint,
- value: mockPreprint,
- },
- {
- selector: PreprintSelectors.getPreprintFile,
- value: mockFile,
- },
- {
- selector: PreprintSelectors.isPreprintFileLoading,
- value: false,
- },
- {
- selector: PreprintSelectors.getPreprintFileVersions,
- value: mockFileVersions,
- },
- {
- selector: PreprintSelectors.arePreprintFileVersionsLoading,
- value: false,
- },
- ],
+ signals: mergeSignalOverrides(
+ [
+ { selector: PreprintSelectors.getPreprint, value: mockPreprint },
+ { selector: PreprintSelectors.getPreprintFile, value: mockFile },
+ { selector: PreprintSelectors.isPreprintFileLoading, value: false },
+ { selector: PreprintSelectors.getPreprintFileVersions, value: mockFileVersions },
+ { selector: PreprintSelectors.arePreprintFileVersionsLoading, value: false },
+ ],
+ overrides.selectorOverrides
+ ),
}),
],
- }).compileComponents();
+ });
fixture = TestBed.createComponent(PreprintFileSectionComponent);
component = fixture.componentInstance;
-
- fixture.componentRef.setInput('providerReviewsWorkflow', ProviderReviewsWorkflow.PreModeration);
-
- dataciteService = TestBed.inject(DataciteService) as jest.MockedObject;
- });
+ fixture.componentRef.setInput(
+ 'providerReviewsWorkflow',
+ overrides.providerReviewsWorkflow ?? ProviderReviewsWorkflow.PreModeration
+ );
+ }
it('should create', () => {
+ setup();
expect(component).toBeTruthy();
});
- it('should return preprint from store', () => {
- const preprint = component.preprint();
- expect(preprint).toBe(mockPreprint);
- });
-
- it('should return file from store', () => {
- const file = component.file();
- expect(file).toBe(mockFile);
- });
-
- it('should return file loading state from store', () => {
- const loading = component.isFileLoading();
- expect(loading).toBe(false);
- });
-
- it('should return file versions from store', () => {
- const versions = component.fileVersions();
- expect(versions).toBe(mockFileVersions);
- });
-
- it('should return file versions loading state from store', () => {
- const loading = component.areFileVersionsLoading();
- expect(loading).toBe(false);
+ it('should expose selector signals', () => {
+ setup();
+ expect(component.preprint()).toBe(mockPreprint);
+ expect(component.file()).toBe(mockFile);
+ expect(component.isFileLoading()).toBe(false);
+ expect(component.fileVersions()).toBe(mockFileVersions);
+ expect(component.areFileVersionsLoading()).toBe(false);
});
it('should compute safe link from file render link', () => {
+ setup();
const safeLink = component.safeLink();
- expect(safeLink).toBeDefined();
+ expect(safeLink).toBe('https://example.com/render');
+ });
+
+ it('should return null safe link when render link is missing', () => {
+ setup({
+ selectorOverrides: [{ selector: PreprintSelectors.getPreprintFile, value: { ...mockFile, links: {} } }],
+ });
+ expect(component.safeLink()).toBeNull();
});
it('should compute version menu items from file versions', () => {
+ setup();
const menuItems = component.versionMenuItems();
expect(menuItems).toHaveLength(2);
- expect(menuItems[0]).toHaveProperty('label');
- expect(menuItems[0]).toHaveProperty('url');
- expect(menuItems[0]).toHaveProperty('command');
+ expect(menuItems[0].label).toBeTruthy();
+ expect(menuItems[0].url).toBe('https://example.com/download/1');
+ expect(menuItems[0].command).toBeDefined();
});
it('should return empty array when no file versions', () => {
+ setup();
jest.spyOn(component, 'fileVersions').mockReturnValue([]);
const menuItems = component.versionMenuItems();
expect(menuItems).toEqual([]);
});
- it('should compute date label for pre-moderation workflow', () => {
- const label = component.dateLabel();
- expect(label).toBe('preprints.details.file.submitted');
+ it('should return empty array when file versions are undefined', () => {
+ setup();
+ jest.spyOn(component, 'fileVersions').mockReturnValue(undefined as unknown as typeof mockFileVersions);
+ const menuItems = component.versionMenuItems();
+ expect(menuItems).toEqual([]);
});
- it('should compute date label for post-moderation workflow', () => {
- fixture.componentRef.setInput('providerReviewsWorkflow', ProviderReviewsWorkflow.PostModeration);
+ it('should compute date label for pre-moderation workflow', () => {
+ setup({ providerReviewsWorkflow: ProviderReviewsWorkflow.PreModeration });
const label = component.dateLabel();
- expect(label).toBe('preprints.details.file.created');
+ expect(label).toBe('preprints.details.file.submitted');
});
- it('should return created label when no reviews workflow', () => {
+ it('should return created label for non-pre-moderation workflows', () => {
+ setup({ providerReviewsWorkflow: ProviderReviewsWorkflow.PostModeration });
+ expect(component.dateLabel()).toBe('preprints.details.file.created');
fixture.componentRef.setInput('providerReviewsWorkflow', null);
const label = component.dateLabel();
expect(label).toBe('preprints.details.file.created');
});
it('should call dataciteService.logIdentifiableDownload when logDownload is called', () => {
+ setup();
component.logDownload();
expect(dataciteService.logIdentifiableDownload).toHaveBeenCalledWith(component.preprint$);
});
it('should call logDownload when version menu item command is executed', () => {
+ setup();
const menuItems = component.versionMenuItems();
expect(menuItems.length).toBeGreaterThan(0);
@@ -180,11 +174,13 @@ describe('PreprintFileSectionComponent', () => {
});
it('should handle isMedium signal', () => {
+ setup();
const isMedium = component.isMedium();
expect(typeof isMedium).toBe('boolean');
});
it('should handle isLarge signal', () => {
+ setup();
const isLarge = component.isLarge();
expect(typeof isLarge).toBe('boolean');
});
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
index 26f737ef9..8b81a88a3 100644
--- 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
@@ -9,24 +9,23 @@ import { Skeleton } from 'primeng/skeleton';
import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, input } from '@angular/core';
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
-import { DomSanitizer } from '@angular/platform-browser';
import { ProviderReviewsWorkflow } from '@osf/features/preprints/enums';
import { PreprintSelectors } from '@osf/features/preprints/store/preprint';
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
import { IS_LARGE, IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens';
+import { SafeUrlPipe } from '@osf/shared/pipes/safe-url.pipe';
import { DataciteService } from '@osf/shared/services/datacite/datacite.service';
@Component({
selector: 'osf-preprint-file-section',
- imports: [LoadingSpinnerComponent, DatePipe, Skeleton, Menu, Button, TranslatePipe],
+ imports: [LoadingSpinnerComponent, Skeleton, Menu, Button, DatePipe, TranslatePipe, SafeUrlPipe],
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);
private readonly translateService = inject(TranslateService);
private readonly destroyRef = inject(DestroyRef);
@@ -38,26 +37,17 @@ export class PreprintFileSectionComponent {
isLarge = toSignal(inject(IS_LARGE));
preprint = select(PreprintSelectors.getPreprint);
+ preprint$ = toObservable(this.preprint);
file = select(PreprintSelectors.getPreprintFile);
- preprint$ = toObservable(select(PreprintSelectors.getPreprint));
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);
- logDownload() {
- this.dataciteService.logIdentifiableDownload(this.preprint$).pipe(takeUntilDestroyed(this.destroyRef)).subscribe();
- }
+ safeLink = computed(() => this.file()?.links.render ?? null);
+ isIframeLoading = true;
versionMenuItems = computed(() => {
- const fileVersions = this.fileVersions();
+ const fileVersions = this.fileVersions() ?? [];
if (!fileVersions.length) return [];
return fileVersions.map((version) => ({
@@ -77,4 +67,8 @@ export class PreprintFileSectionComponent {
? 'preprints.details.file.submitted'
: 'preprints.details.file.created';
});
+
+ logDownload() {
+ this.dataciteService.logIdentifiableDownload(this.preprint$).pipe(takeUntilDestroyed(this.destroyRef)).subscribe();
+ }
}
diff --git a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html
index 66c43fbaf..f4f6f0f1a 100644
--- a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html
+++ b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html
@@ -1,10 +1,10 @@
-