From c1bbe45a1bfce22d6c9b808f20c1e416a72d6475 Mon Sep 17 00:00:00 2001 From: nsemets Date: Tue, 24 Feb 2026 14:11:19 +0200 Subject: [PATCH 1/3] test(preprint-pages): updated unit tests for preprint pages --- .../preprints/mappers/preprints.mapper.ts | 2 +- .../create-new-version.component.html | 8 +- .../create-new-version.component.spec.ts | 211 +++--- .../create-new-version.component.ts | 47 +- src/app/features/preprints/pages/index.ts | 3 - .../preprints-landing.component.spec.ts | 171 ----- .../my-preprints/my-preprints.component.html | 8 +- .../my-preprints.component.spec.ts | 218 ++---- .../my-preprints/my-preprints.component.ts | 42 +- .../preprint-details.component.html | 4 +- .../preprint-details.component.spec.ts | 681 ++++++++++-------- .../preprint-details.component.ts | 100 +-- ...print-pending-moderation.component.spec.ts | 11 +- .../preprint-pending-moderation.component.ts | 2 +- ...eprint-provider-discover.component.spec.ts | 149 ++-- .../preprint-provider-discover.component.ts | 48 +- ...eprint-provider-overview.component.spec.ts | 173 ++--- .../preprint-provider-overview.component.ts | 23 +- .../preprints-landing.component.html | 26 +- .../preprints-landing.component.scss | 0 .../preprints-landing.component.spec.ts | 144 ++++ .../preprints-landing.component.ts | 48 +- .../select-preprint-service.component.html | 6 +- .../select-preprint-service.component.spec.ts | 153 ++-- .../select-preprint-service.component.ts | 13 +- .../submit-preprint-stepper.component.html | 16 +- .../submit-preprint-stepper.component.spec.ts | 258 ++++--- .../submit-preprint-stepper.component.ts | 74 +- .../update-preprint-stepper.component.html | 4 +- .../update-preprint-stepper.component.spec.ts | 309 +++++--- .../update-preprint-stepper.component.ts | 84 +-- .../preprints/preprints.component.spec.ts | 14 +- .../features/preprints/preprints.routes.ts | 2 +- .../preprint-stepper.selectors.ts | 6 + .../registry-provider-hero.component.html | 6 +- src/testing/providers/brand-service.mock.ts | 15 + .../providers/browser-tab-service.mock.ts | 15 + .../providers/header-style-service.mock.ts | 15 + 38 files changed, 1628 insertions(+), 1481 deletions(-) delete mode 100644 src/app/features/preprints/pages/index.ts delete mode 100644 src/app/features/preprints/pages/landing/preprints-landing.component.spec.ts rename src/app/features/preprints/pages/{landing => preprints-landing}/preprints-landing.component.html (79%) rename src/app/features/preprints/pages/{landing => preprints-landing}/preprints-landing.component.scss (100%) create mode 100644 src/app/features/preprints/pages/preprints-landing/preprints-landing.component.spec.ts rename src/app/features/preprints/pages/{landing => preprints-landing}/preprints-landing.component.ts (72%) create mode 100644 src/testing/providers/brand-service.mock.ts create mode 100644 src/testing/providers/browser-tab-service.mock.ts create mode 100644 src/testing/providers/header-style-service.mock.ts diff --git a/src/app/features/preprints/mappers/preprints.mapper.ts b/src/app/features/preprints/mappers/preprints.mapper.ts index 23f72d0be..7d2e8defa 100644 --- a/src/app/features/preprints/mappers/preprints.mapper.ts +++ b/src/app/features/preprints/mappers/preprints.mapper.ts @@ -47,7 +47,7 @@ export class PreprintsMapper { datePublished: response.attributes.date_published, dateLastTransitioned: response.attributes.date_last_transitioned, title: replaceBadEncodedChars(response.attributes.title), - description: response.attributes.description, + description: replaceBadEncodedChars(response.attributes.description), reviewsState: response.attributes.reviews_state, preprintDoiCreated: response.attributes.preprint_doi_created, currentUserPermissions: response.attributes.current_user_permissions, diff --git a/src/app/features/preprints/pages/create-new-version/create-new-version.component.html b/src/app/features/preprints/pages/create-new-version/create-new-version.component.html index 9d7f9fd03..c91017461 100644 --- a/src/app/features/preprints/pages/create-new-version/create-new-version.component.html +++ b/src/app/features/preprints/pages/create-new-version/create-new-version.component.html @@ -6,9 +6,9 @@ } @else {

{{ 'preprints.createNewVersionTitle' | translate }} @@ -33,11 +33,7 @@

@switch (currentStep().value) { @case (PreprintSteps.File) { - + } @case (PreprintSteps.Review) { diff --git a/src/app/features/preprints/pages/create-new-version/create-new-version.component.spec.ts b/src/app/features/preprints/pages/create-new-version/create-new-version.component.spec.ts index 2f76e66ea..482256b6f 100644 --- a/src/app/features/preprints/pages/create-new-version/create-new-version.component.spec.ts +++ b/src/app/features/preprints/pages/create-new-version/create-new-version.component.spec.ts @@ -1,3 +1,5 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; @@ -7,7 +9,6 @@ import { ActivatedRoute, Router } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; -import { StepOption } from '@osf/shared/models/step-option.model'; import { BrandService } from '@osf/shared/services/brand.service'; import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; import { HeaderStyleService } from '@osf/shared/services/header-style.service'; @@ -16,181 +17,185 @@ import { FileStepComponent, ReviewStepComponent } from '../../components'; import { createNewVersionStepsConst } from '../../constants'; import { PreprintSteps } from '../../enums'; import { PreprintProviderDetails } from '../../models'; -import { PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { PreprintStepperSelectors } from '../../store/preprint-stepper'; +import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; +import { FetchPreprintById, PreprintStepperSelectors, ResetPreprintStepperState } from '../../store/preprint-stepper'; import { CreateNewVersionComponent } from './create-new-version.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 { BrandServiceMock, BrandServiceMockType } from '@testing/providers/brand-service.mock'; +import { BrowserTabServiceMock, BrowserTabServiceMockType } from '@testing/providers/browser-tab-service.mock'; +import { HeaderStyleServiceMock, HeaderStyleServiceMockType } from '@testing/providers/header-style-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; describe('CreateNewVersionComponent', () => { let component: CreateNewVersionComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; - let routeMock: ReturnType; + let store: Store; + let routerMock: RouterMockType; + let brandServiceMock: BrandServiceMockType; + let headerStyleMock: HeaderStyleServiceMockType; + let browserTabMock: BrowserTabServiceMockType; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; - const mockPreprint = PREPRINT_MOCK; const mockProviderId = 'osf'; const mockPreprintId = 'test_preprint_123'; - beforeEach(async () => { + const defaultSignals: SignalOverride[] = [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: mockProvider }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + { selector: PreprintStepperSelectors.hasBeenSubmitted, value: false }, + ]; + + function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); - routeMock = ActivatedRouteMockBuilder.create() + const routeMock = ActivatedRouteMockBuilder.create() .withParams({ providerId: mockProviderId, preprintId: mockPreprintId }) .withQueryParams({}) .build(); - await TestBed.configureTestingModule({ - imports: [ - CreateNewVersionComponent, - OSFTestingModule, - ...MockComponents(StepperComponent, FileStepComponent, ReviewStepComponent), - ], + brandServiceMock = BrandServiceMock.simple(); + headerStyleMock = HeaderStyleServiceMock.simple(); + browserTabMock = BrowserTabServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [CreateNewVersionComponent, ...MockComponents(StepperComponent, FileStepComponent, ReviewStepComponent)], providers: [ - TranslationServiceMock, - MockProvider(BrandService), - MockProvider(BrowserTabService), - MockProvider(HeaderStyleService), - MockProvider(Router, routerMock), + provideOSFCore(), MockProvider(ActivatedRoute, routeMock), + MockProvider(Router, routerMock), + MockProvider(BrandService, brandServiceMock), + MockProvider(HeaderStyleService, headerStyleMock), + MockProvider(BrowserTabService, browserTabMock), MockProvider(IS_WEB, of(true)), - provideMockStore({ - signals: [ - { - selector: PreprintStepperSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.hasBeenSubmitted, - value: false, - }, - ], - }), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(CreateNewVersionComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } afterEach(() => { - if (fixture) { - fixture.destroy(); - } + fixture?.destroy(); jest.restoreAllMocks(); }); it('should initialize with correct default values', () => { + setup(); + expect(component.PreprintSteps).toBe(PreprintSteps); expect(component.newVersionSteps).toBe(createNewVersionStepsConst); expect(component.currentStep()).toEqual(createNewVersionStepsConst[0]); expect(component.classes).toBe('flex-1 flex flex-column w-full'); }); - it('should return preprint from store', () => { - const preprint = component.preprint(); - expect(preprint).toBe(mockPreprint); - }); + it('should dispatch initial actions on creation', () => { + setup(); - it('should return preprint provider from store', () => { - const provider = component.preprintProvider(); - expect(provider).toBe(mockProvider); + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProviderById(mockProviderId)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintById(mockPreprintId)); }); - it('should return loading state from store', () => { - const loading = component.isPreprintProviderLoading(); - expect(loading).toBe(false); - }); + it('should apply branding when provider is available', () => { + setup(); - it('should return submission state from store', () => { - const submitted = component.hasBeenSubmitted(); - expect(submitted).toBe(false); + expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(mockProvider.brand); + expect(headerStyleMock.applyHeaderStyles).toHaveBeenCalledWith( + mockProvider.brand.primaryColor, + mockProvider.brand.secondaryColor, + mockProvider.brand.heroBackgroundImageUrl + ); + expect(browserTabMock.updateTabStyles).toHaveBeenCalledWith(mockProvider.faviconUrl, mockProvider.name); }); - it('should return web environment state', () => { - const isWeb = component.isWeb(); - expect(typeof isWeb).toBe('boolean'); - }); + it('should reset services on destroy', () => { + setup(); - it('should initialize with first step as current step', () => { - expect(component.currentStep()).toEqual(createNewVersionStepsConst[0]); + component.ngOnDestroy(); + + expect(headerStyleMock.resetToDefaults).toHaveBeenCalled(); + expect(brandServiceMock.resetBranding).toHaveBeenCalled(); + expect(browserTabMock.resetToDefaults).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new ResetPreprintStepperState()); }); - it('should handle step change when moving to previous step', () => { - const previousStep = createNewVersionStepsConst[0]; + it('should prevent beforeunload when not submitted', () => { + setup(); + const event = { preventDefault: jest.fn() } as unknown as BeforeUnloadEvent; - component.stepChange(previousStep); + component.onBeforeUnload(event); - expect(component.currentStep()).toEqual(previousStep); + expect(event.preventDefault).toHaveBeenCalled(); }); - it('should not change step when moving to next step', () => { - const currentStep = component.currentStep(); - const nextStep = createNewVersionStepsConst[1]; + it('should not prevent beforeunload when submitted', () => { + setup({ selectorOverrides: [{ selector: PreprintStepperSelectors.hasBeenSubmitted, value: true }] }); + const event = { preventDefault: jest.fn() } as unknown as BeforeUnloadEvent; - component.stepChange(nextStep); + component.onBeforeUnload(event); - expect(component.currentStep()).toEqual(currentStep); + expect(event.preventDefault).not.toHaveBeenCalled(); }); - it('should move to next step', () => { - const currentIndex = component.currentStep()?.index ?? 0; - const nextStep = createNewVersionStepsConst[currentIndex + 1]; - - component.moveToNextStep(); + it('should prevent deactivation when not submitted', () => { + setup(); - expect(component.currentStep()).toEqual(nextStep); + expect(component.canDeactivate()).toBe(false); }); - it('should navigate to previous step (preprint page)', () => { - component.moveToPreviousStep(); + it('should allow deactivation when submitted', () => { + setup({ selectorOverrides: [{ selector: PreprintStepperSelectors.hasBeenSubmitted, value: true }] }); - expect(routerMock.navigate).toHaveBeenCalledWith([mockPreprintId.split('_')[0]]); + expect(component.canDeactivate()).toBe(true); }); - it('should return canDeactivate state', () => { - const canDeactivate = component.canDeactivate(); - expect(canDeactivate).toBe(false); + it('should ignore stepping forward via stepper', () => { + setup(); + + component.stepChange(createNewVersionStepsConst[1]); + + expect(component.currentStep()).toEqual(createNewVersionStepsConst[0]); }); - it('should handle beforeunload event', () => { - const event = { - preventDefault: jest.fn(), - } as unknown as BeforeUnloadEvent; + it('should allow stepping back via stepper', () => { + setup(); + component.moveToNextStep(); - const result = component.onBeforeUnload(event); + component.stepChange(createNewVersionStepsConst[0]); - expect(event.preventDefault).toHaveBeenCalled(); - expect(result).toBe(false); + expect(component.currentStep()).toEqual(createNewVersionStepsConst[0]); }); - it('should handle step navigation correctly', () => { + it('should move to next step', () => { + setup(); + component.moveToNextStep(); + expect(component.currentStep()).toEqual(createNewVersionStepsConst[1]); + }); - component.stepChange(createNewVersionStepsConst[0]); - expect(component.currentStep()).toEqual(createNewVersionStepsConst[0]); + it('should not move past the last step', () => { + setup(); + component.currentStep.set(createNewVersionStepsConst[createNewVersionStepsConst.length - 1]); + + component.moveToNextStep(); + + expect(component.currentStep()).toEqual(createNewVersionStepsConst[createNewVersionStepsConst.length - 1]); }); - it('should handle edge case when moving to next step with undefined current step', () => { - component.currentStep.set({} as StepOption); + it('should navigate back to preprint page', () => { + setup(); - expect(() => component.moveToNextStep()).not.toThrow(); + component.navigateBack(); + + expect(routerMock.navigate).toHaveBeenCalledWith([mockPreprintId.split('_')[0]]); }); }); diff --git a/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts b/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts index 253a9d8df..122819273 100644 --- a/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts +++ b/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts @@ -4,7 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Skeleton } from 'primeng/skeleton'; -import { map, Observable, of } from 'rxjs'; +import { map } from 'rxjs'; import { ChangeDetectionStrategy, @@ -14,7 +14,6 @@ import { HostListener, inject, OnDestroy, - OnInit, signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -46,7 +45,7 @@ import { styleUrl: './create-new-version.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CreateNewVersionComponent implements OnInit, OnDestroy, CanDeactivateComponent { +export class CreateNewVersionComponent implements OnDestroy, CanDeactivateComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; private route = inject(ActivatedRoute); @@ -55,8 +54,8 @@ export class CreateNewVersionComponent implements OnInit, OnDestroy, CanDeactiva private headerStyleHelper = inject(HeaderStyleService); private browserTabHelper = inject(BrowserTabService); - private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); - private preprintId = toSignal(this.route.params.pipe(map((params) => params['preprintId'])) ?? of(undefined)); + private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId']))); + private preprintId = toSignal(this.route.params.pipe(map((params) => params['preprintId']))); private actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, @@ -68,7 +67,6 @@ export class CreateNewVersionComponent implements OnInit, OnDestroy, CanDeactiva readonly PreprintSteps = PreprintSteps; readonly newVersionSteps = createNewVersionStepsConst; - preprint = select(PreprintStepperSelectors.getPreprint); preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); @@ -76,6 +74,9 @@ export class CreateNewVersionComponent implements OnInit, OnDestroy, CanDeactiva isWeb = toSignal(inject(IS_WEB)); constructor() { + this.actions.getPreprintProviderById(this.providerId()); + this.actions.fetchPreprint(this.preprintId()); + effect(() => { const provider = this.preprintProvider(); @@ -93,14 +94,10 @@ export class CreateNewVersionComponent implements OnInit, OnDestroy, CanDeactiva } @HostListener('window:beforeunload', ['$event']) - onBeforeUnload($event: BeforeUnloadEvent): boolean { - $event.preventDefault(); - return false; - } - - ngOnInit() { - this.actions.getPreprintProviderById(this.providerId()); - this.actions.fetchPreprint(this.preprintId()); + onBeforeUnload($event: BeforeUnloadEvent): void { + if (!this.hasBeenSubmitted()) { + $event.preventDefault(); + } } ngOnDestroy() { @@ -110,25 +107,31 @@ export class CreateNewVersionComponent implements OnInit, OnDestroy, CanDeactiva this.actions.resetState(); } - canDeactivate(): Observable | boolean { + canDeactivate(): boolean { return this.hasBeenSubmitted(); } stepChange(step: StepOption): void { - const currentStepIndex = this.currentStep()?.index ?? 0; - if (step.index >= currentStepIndex) { + if (step.index >= this.currentStep().index) { return; } this.currentStep.set(step); } - moveToNextStep() { - this.currentStep.set(this.newVersionSteps[this.currentStep()?.index + 1]); + moveToNextStep(): void { + const nextStep = this.newVersionSteps[this.currentStep().index + 1]; + + if (nextStep) { + this.currentStep.set(nextStep); + } } - moveToPreviousStep() { - const id = this.preprintId().split('_')[0]; - this.router.navigate([id]); + navigateBack(): void { + const id = this.preprintId()?.split('_')[0]; + + if (id) { + this.router.navigate([id]); + } } } diff --git a/src/app/features/preprints/pages/index.ts b/src/app/features/preprints/pages/index.ts deleted file mode 100644 index e79f5f462..000000000 --- a/src/app/features/preprints/pages/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { PreprintsLandingComponent } from '@osf/features/preprints/pages/landing/preprints-landing.component'; -export { PreprintProviderDiscoverComponent } from '@osf/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component'; -export { PreprintProviderOverviewComponent } from '@osf/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component'; diff --git a/src/app/features/preprints/pages/landing/preprints-landing.component.spec.ts b/src/app/features/preprints/pages/landing/preprints-landing.component.spec.ts deleted file mode 100644 index 6be95e3fb..000000000 --- a/src/app/features/preprints/pages/landing/preprints-landing.component.spec.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { MockComponents, MockPipe, MockProvider } from 'ng-mocks'; - -import { TitleCasePipe } from '@angular/common'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Router } from '@angular/router'; - -import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { BrandService } from '@osf/shared/services/brand.service'; - -import { AdvisoryBoardComponent, BrowseBySubjectsComponent, PreprintServicesComponent } from '../../components'; -import { PreprintProviderDetails } from '../../models'; -import { PreprintProvidersSelectors } from '../../store/preprint-providers'; - -import { PreprintsLandingComponent } from './preprints-landing.component'; - -import { EnvironmentTokenMock } from '@testing/mocks/environment.token.mock'; -import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; -import { PREPRINT_PROVIDER_SHORT_INFO_MOCK } from '@testing/mocks/preprint-provider-short-info.mock'; -import { SUBJECTS_MOCK } from '@testing/mocks/subject.mock'; -import { TranslationServiceMock } from '@testing/mocks/translation.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; - -describe('PreprintsLandingComponent', () => { - let component: PreprintsLandingComponent; - let fixture: ComponentFixture; - let routerMock: ReturnType; - - const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; - const mockProvidersToAdvertise = [PREPRINT_PROVIDER_SHORT_INFO_MOCK]; - const mockHighlightedSubjects = SUBJECTS_MOCK; - const mockDefaultProvider = 'osf'; - - beforeEach(async () => { - routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); - - await TestBed.configureTestingModule({ - imports: [ - PreprintsLandingComponent, - OSFTestingModule, - ...MockComponents( - SearchInputComponent, - AdvisoryBoardComponent, - PreprintServicesComponent, - BrowseBySubjectsComponent - ), - MockPipe(TitleCasePipe), - ], - providers: [ - TranslationServiceMock, - EnvironmentTokenMock, - MockProvider(BrandService), - MockProvider(Router, routerMock), - provideMockStore({ - signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockDefaultProvider), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - { - selector: PreprintProvidersSelectors.getPreprintProvidersToAdvertise, - value: mockProvidersToAdvertise, - }, - { - selector: PreprintProvidersSelectors.getHighlightedSubjectsForProvider, - value: mockHighlightedSubjects, - }, - { - selector: PreprintProvidersSelectors.areSubjectsLoading, - value: false, - }, - ], - }), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(PreprintsLandingComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with correct default values', () => { - expect(component.searchControl.value).toBe(''); - expect(component.supportEmail).toBeDefined(); - }); - - it('should return preprint provider from store', () => { - const provider = component.osfPreprintProvider(); - expect(provider).toBe(mockProvider); - }); - - it('should return loading state from store', () => { - const loading = component.isPreprintProviderLoading(); - expect(loading).toBe(false); - }); - - it('should return providers to advertise from store', () => { - const providers = component.preprintProvidersToAdvertise(); - expect(providers).toBe(mockProvidersToAdvertise); - }); - - it('should return highlighted subjects from store', () => { - const subjects = component.highlightedSubjectsByProviderId(); - expect(subjects).toBe(mockHighlightedSubjects); - }); - - it('should return subjects loading state from store', () => { - const loading = component.areSubjectsLoading(); - expect(loading).toBe(false); - }); - - it('should have correct CSS classes', () => { - expect(component.classes).toBe('flex-1 flex flex-column w-full h-full'); - }); - - it('should navigate to search page with search value', () => { - component.searchControl.setValue('test search'); - - component.redirectToSearchPageWithValue(); - - expect(routerMock.navigate).toHaveBeenCalledWith(['/search'], { - queryParams: { search: 'test search', tab: ResourceType.Preprint }, - }); - }); - - it('should navigate to search page with empty search value', () => { - component.searchControl.setValue(''); - - component.redirectToSearchPageWithValue(); - - expect(routerMock.navigate).toHaveBeenCalledWith(['/search'], { - queryParams: { search: '', tab: ResourceType.Preprint }, - }); - }); - - it('should navigate to search page with null search value', () => { - component.searchControl.setValue(null); - - component.redirectToSearchPageWithValue(); - - expect(routerMock.navigate).toHaveBeenCalledWith(['/search'], { - queryParams: { search: null, tab: ResourceType.Preprint }, - }); - }); - - it('should handle search control value changes', () => { - const testValue = 'new search term'; - component.searchControl.setValue(testValue); - expect(component.searchControl.value).toBe(testValue); - }); - - it('should have readonly properties', () => { - expect(component.supportEmail).toBeDefined(); - expect(typeof component.supportEmail).toBe('string'); - }); - - it('should initialize form control correctly', () => { - expect(component.searchControl).toBeDefined(); - expect(component.searchControl.value).toBe(''); - }); -}); diff --git a/src/app/features/preprints/pages/my-preprints/my-preprints.component.html b/src/app/features/preprints/pages/my-preprints/my-preprints.component.html index 3bb682bad..ba97b2d2a 100644 --- a/src/app/features/preprints/pages/my-preprints/my-preprints.component.html +++ b/src/app/features/preprints/pages/my-preprints/my-preprints.component.html @@ -4,7 +4,7 @@ [icon]="'custom-icon-preprints-dark'" [showButton]="true" [buttonLabel]="'preprints.addPreprint' | translate: { preprintWord: 'preprint' | titlecase }" - (buttonClick)="addPreprintBtnClicked()" + (buttonClick)="navigateToAddPreprint()" />
@@ -52,7 +52,7 @@ @if (item?.id) { - {{ item.title | fixSpecialChar }} + {{ item.title }} @@ -60,7 +60,7 @@ } @else { - + @@ -68,7 +68,7 @@ - {{ 'common.search.noResultsFound' | translate }} + {{ 'common.search.noResultsFound' | translate }} diff --git a/src/app/features/preprints/pages/my-preprints/my-preprints.component.spec.ts b/src/app/features/preprints/pages/my-preprints/my-preprints.component.spec.ts index b907ff6a1..f7b7eee04 100644 --- a/src/app/features/preprints/pages/my-preprints/my-preprints.component.spec.ts +++ b/src/app/features/preprints/pages/my-preprints/my-preprints.component.spec.ts @@ -1,3 +1,5 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockPipe, MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; @@ -13,27 +15,26 @@ import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params import { SortOrder } from '@osf/shared/enums/sort-order.enum'; import { PreprintShortInfo } from '../../models'; -import { MyPreprintsSelectors } from '../../store/my-preprints'; +import { FetchMyPreprints, MyPreprintsSelectors } from '../../store/my-preprints'; import { MyPreprintsComponent } from './my-preprints.component'; import { PREPRINT_SHORT_INFO_ARRAY_MOCK } from '@testing/mocks/preprint-short-info.mock'; -import { TranslationServiceMock } from '@testing/mocks/translation.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('MyPreprintsComponent', () => { let component: MyPreprintsComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; - let activatedRouteMock: ReturnType; + let store: Store; + let routerMock: RouterMockType; let queryParamsSubject: BehaviorSubject>; const mockPreprints: PreprintShortInfo[] = PREPRINT_SHORT_INFO_ARRAY_MOCK; - beforeEach(async () => { + function setup() { queryParamsSubject = new BehaviorSubject>({}); routerMock = RouterMockBuilder.create() @@ -41,83 +42,75 @@ describe('MyPreprintsComponent', () => { .withNavigateByUrl(jest.fn().mockResolvedValue(true)) .build(); - activatedRouteMock = ActivatedRouteMockBuilder.create() - .withQueryParams({ page: '1', size: '10', search: '' }) - .build(); - - Object.defineProperty(activatedRouteMock, 'queryParams', { - value: queryParamsSubject.asObservable(), - writable: true, - }); + const activatedRouteMock = ActivatedRouteMockBuilder.create().build(); + activatedRouteMock.queryParams = queryParamsSubject.asObservable(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ MyPreprintsComponent, - OSFTestingModule, ...MockComponents(SubHeaderComponent, SearchInputComponent, ContributorsListShortenerComponent), MockPipe(TitleCasePipe), ], providers: [ - TranslationServiceMock, + provideOSFCore(), MockProvider(Router, routerMock), MockProvider(ActivatedRoute, activatedRouteMock), provideMockStore({ signals: [ - { - selector: MyPreprintsSelectors.getMyPreprints, - value: mockPreprints, - }, - { - selector: MyPreprintsSelectors.getMyPreprintsTotalCount, - value: 5, - }, - { - selector: MyPreprintsSelectors.areMyPreprintsLoading, - value: false, - }, + { selector: MyPreprintsSelectors.getMyPreprints, value: mockPreprints }, + { selector: MyPreprintsSelectors.getMyPreprintsTotalCount, value: 5 }, + { selector: MyPreprintsSelectors.areMyPreprintsLoading, value: false }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(MyPreprintsComponent); component = fixture.componentInstance; fixture.detectChanges(); + } + + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); }); it('should initialize with correct default values', () => { + setup(); + expect(component.searchControl.value).toBe(''); expect(component.sortColumn()).toBe(''); expect(component.sortOrder()).toBe(SortOrder.Desc); - expect(component.currentPage()).toBe(1); - expect(component.currentPageSize()).toBe(DEFAULT_TABLE_PARAMS.rows); + expect(component.classes).toBe('flex-1 flex flex-column w-full'); + expect(component.skeletonData).toHaveLength(10); }); - it('should return preprints from store', () => { - const preprints = component.preprints(); - expect(preprints).toBe(mockPreprints); - }); + it('should dispatch FetchMyPreprints on init', () => { + setup(); - it('should return preprints total count from store', () => { - const totalCount = component.preprintsTotalCount(); - expect(totalCount).toBe(5); + expect(store.dispatch).toHaveBeenCalledWith( + new FetchMyPreprints(1, 10, { + searchValue: '', + searchFields: ['title', 'tags', 'description'], + sortColumn: 'dateModified', + sortOrder: SortOrder.Desc, + }) + ); }); - it('should return loading state from store', () => { - const loading = component.areMyPreprintsLoading(); - expect(loading).toBe(false); - }); + it('should have correct table parameters after init', () => { + setup(); - it('should have correct CSS classes', () => { - expect(component.classes).toBe('flex-1 flex flex-column w-full'); - }); - - it('should have skeleton data with correct length', () => { - expect(component.skeletonData).toHaveLength(10); - expect(component.skeletonData.every((item) => typeof item === 'object')).toBe(true); + expect(component.tableParams()).toEqual({ + ...DEFAULT_TABLE_PARAMS, + firstRowIndex: 0, + totalRecords: 5, + }); }); - it('should navigate to preprint details when navigateToPreprintDetails is called', () => { + it('should navigate to preprint details', () => { + setup(); const mockPreprint: PreprintShortInfo = { id: 'preprint-1', title: 'Test Preprint', @@ -131,13 +124,10 @@ describe('MyPreprintsComponent', () => { expect(routerMock.navigateByUrl).toHaveBeenCalledWith('/preprints/provider-1/preprint-1'); }); - it('should handle page change correctly', () => { - const mockEvent = { - first: 20, - rows: 10, - }; + it('should update query params on page change', () => { + setup(); - component.onPageChange(mockEvent); + component.onPageChange({ first: 20, rows: 10 }); expect(routerMock.navigate).toHaveBeenCalledWith([], { relativeTo: expect.any(Object), @@ -146,13 +136,10 @@ describe('MyPreprintsComponent', () => { }); }); - it('should handle sort correctly for ascending order', () => { - const mockEvent = { - field: 'title', - order: 1, - }; + it('should update query params on ascending sort', () => { + setup(); - component.onSort(mockEvent); + component.onSort({ field: 'title', order: 1 }); expect(routerMock.navigate).toHaveBeenCalledWith([], { relativeTo: expect.any(Object), @@ -161,13 +148,10 @@ describe('MyPreprintsComponent', () => { }); }); - it('should handle sort correctly for descending order', () => { - const mockEvent = { - field: 'title', - order: -1, - }; + it('should update query params on descending sort', () => { + setup(); - component.onSort(mockEvent); + component.onSort({ field: 'title', order: -1 }); expect(routerMock.navigate).toHaveBeenCalledWith([], { relativeTo: expect.any(Object), @@ -177,90 +161,40 @@ describe('MyPreprintsComponent', () => { }); it('should not navigate when sort field is undefined', () => { - const mockEvent = { - field: undefined, - order: 1, - }; + setup(); - component.onSort(mockEvent); + component.onSort({ field: undefined, order: 1 }); expect(routerMock.navigate).not.toHaveBeenCalled(); }); - it('should navigate to add preprint page when addPreprintBtnClicked is called', () => { - component.addPreprintBtnClicked(); + it('should navigate to add preprint page', () => { + setup(); - expect(routerMock.navigateByUrl).toHaveBeenCalledWith('/preprints/select'); - }); + component.navigateToAddPreprint(); - it('should handle search control value changes', () => { - const testValue = 'test search'; - component.searchControl.setValue(testValue); - expect(component.searchControl.value).toBe(testValue); + expect(routerMock.navigateByUrl).toHaveBeenCalledWith('/preprints/select'); }); - it('should update component state when query params change', () => { - queryParamsSubject.next({ page: '2', size: '20', search: 'test' }); + it('should update state and re-dispatch when query params change', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + queryParamsSubject.next({ page: '2', size: '20', search: 'test', sortColumn: 'title', sortOrder: 'asc' }); fixture.detectChanges(); - expect(component.currentPage()).toBe(2); - expect(component.currentPageSize()).toBe(20); expect(component.searchControl.value).toBe('test'); - }); - - it('should initialize form control correctly', () => { - expect(component.searchControl).toBeDefined(); - expect(component.searchControl.value).toBe(''); - }); - - it('should have correct table parameters', () => { - const tableParams = component.tableParams(); - expect(tableParams).toEqual({ - ...DEFAULT_TABLE_PARAMS, - firstRowIndex: 0, - totalRecords: 5, - }); - }); - - it('should update table parameters when total records change', () => { - const newTableParams = { totalRecords: 100 }; - component['updateTableParams'](newTableParams); - - const updatedParams = component.tableParams(); - expect(updatedParams.totalRecords).toBe(100); - }); - - it('should create filters correctly', () => { - const mockParams = { - page: 1, - size: 10, - search: 'test search', - sortColumn: 'title', - sortOrder: SortOrder.Desc, - }; - - const filters = component['createFilters'](mockParams); - - expect(filters).toEqual({ - searchValue: 'test search', - searchFields: ['title', 'tags', 'description'], - sortColumn: 'title', - sortOrder: SortOrder.Desc, - }); - }); - - it('should handle empty search value in filters', () => { - const mockParams = { - page: 1, - size: 10, - search: '', - sortColumn: 'title', - sortOrder: SortOrder.Asc, - }; - - const filters = component['createFilters'](mockParams); - - expect(filters.searchValue).toBe(''); + expect(component.sortColumn()).toBe('title'); + expect(component.sortOrder()).toBe(SortOrder.Asc); + expect(component.tableParams().rows).toBe(20); + expect(component.tableParams().firstRowIndex).toBe(20); + expect(store.dispatch).toHaveBeenCalledWith( + new FetchMyPreprints(2, 20, { + searchValue: 'test', + searchFields: ['title', 'tags', 'description'], + sortColumn: 'title', + sortOrder: SortOrder.Asc, + }) + ); }); }); diff --git a/src/app/features/preprints/pages/my-preprints/my-preprints.component.ts b/src/app/features/preprints/pages/my-preprints/my-preprints.component.ts index f85efb139..79a9f5cac 100644 --- a/src/app/features/preprints/pages/my-preprints/my-preprints.component.ts +++ b/src/app/features/preprints/pages/my-preprints/my-preprints.component.ts @@ -32,7 +32,6 @@ import { parseQueryFilterParams } from '@osf/shared/helpers/http.helper'; import { QueryParams } from '@osf/shared/models/query-params.model'; import { SearchFilters } from '@osf/shared/models/search-filters.model'; import { TableParameters } from '@osf/shared/models/table-parameters.model'; -import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe'; import { PreprintShortInfo } from '../../models'; import { FetchMyPreprints, MyPreprintsSelectors } from '../../store/my-preprints'; @@ -40,15 +39,14 @@ import { FetchMyPreprints, MyPreprintsSelectors } from '../../store/my-preprints @Component({ selector: 'osf-my-preprints', imports: [ - SubHeaderComponent, - SearchInputComponent, - TranslatePipe, TableModule, Skeleton, - DatePipe, + SearchInputComponent, + SubHeaderComponent, ContributorsListShortenerComponent, + DatePipe, + TranslatePipe, TitleCasePipe, - FixSpecialCharPipe, ], templateUrl: './my-preprints.component.html', styleUrl: './my-preprints.component.scss', @@ -56,19 +54,17 @@ import { FetchMyPreprints, MyPreprintsSelectors } from '../../store/my-preprints }) export class MyPreprintsComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; + private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); private readonly actions = createDispatchMap({ fetchMyPreprints: FetchMyPreprints }); - private readonly defaultSortColumn = 'dateModified'; - searchControl = new FormControl(''); + searchControl = new FormControl('', { nonNullable: true }); - queryParams = toSignal(this.route.queryParams); + private readonly queryParams = toSignal(this.route.queryParams); sortColumn = signal(''); sortOrder = signal(SortOrder.Desc); - currentPage = signal(1); - currentPageSize = signal(DEFAULT_TABLE_PARAMS.rows); tableParams = signal({ ...DEFAULT_TABLE_PARAMS, firstRowIndex: 0 }); preprints = select(MyPreprintsSelectors.getMyPreprints); @@ -99,12 +95,16 @@ export class MyPreprintsComponent { if (event.field) { this.updateQueryParams({ sortColumn: event.field, - sortOrder: event.order as SortOrder.Asc, + sortOrder: event.order === 1 ? SortOrder.Asc : SortOrder.Desc, }); } } - setupQueryParamsEffect(): void { + navigateToAddPreprint(): void { + this.router.navigateByUrl('/preprints/select'); + } + + private setupQueryParamsEffect(): void { effect(() => { const rawQueryParams = this.queryParams(); if (!rawQueryParams) return; @@ -119,16 +119,16 @@ export class MyPreprintsComponent { private setupSearchSubscription(): void { this.searchControl.valueChanges - .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef), skip(1)) - .subscribe((searchControl) => { + .pipe(skip(1), debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { this.updateQueryParams({ - search: searchControl ?? '', + search: value, page: 1, }); }); } - private setupTotalRecordsEffect() { + private setupTotalRecordsEffect(): void { effect(() => { const totalRecords = this.preprintsTotalCount(); untracked(() => { @@ -169,8 +169,6 @@ export class MyPreprintsComponent { private updateComponentState(params: QueryParams): void { untracked(() => { - this.currentPage.set(params.page); - this.currentPageSize.set(params.size); this.searchControl.setValue(params.search); this.sortColumn.set(params.sortColumn); this.sortOrder.set(params.sortOrder); @@ -186,12 +184,8 @@ export class MyPreprintsComponent { return { searchValue: params.search, searchFields: ['title', 'tags', 'description'], - sortColumn: params.sortColumn || this.defaultSortColumn, + sortColumn: params.sortColumn || 'dateModified', sortOrder: params.sortOrder, }; } - - addPreprintBtnClicked() { - this.router.navigateByUrl('/preprints/select'); - } } diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.html b/src/app/features/preprints/pages/preprint-details/preprint-details.component.html index 54e5ac20f..eba4c1d04 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.html +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.html @@ -13,11 +13,11 @@ > Provider Logo -

{{ preprint()?.title | fixSpecialChar }}

+

{{ preprint()?.title }}

} diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts index 1fbcfc53f..4f4724b42 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts @@ -2,9 +2,9 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; +import { Observable, of, throwError } from 'rxjs'; -import { HttpTestingController } from '@angular/common/http/testing'; +import { HttpErrorResponse } from '@angular/common/http'; import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideServerRendering } from '@angular/platform-server'; @@ -12,10 +12,11 @@ import { ActivatedRoute, Router } from '@angular/router'; import { HelpScoutService } from '@core/services/help-scout.service'; import { PrerenderReadyService } from '@core/services/prerender-ready.service'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { ClearCurrentProvider } from '@core/store/provider'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; +import { ToastService } from '@osf/shared/services/toast.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; import { @@ -31,8 +32,16 @@ import { StatusBannerComponent, } from '../../components'; import { ReviewsState } from '../../enums'; -import { PreprintSelectors } from '../../store/preprint'; -import { PreprintProvidersSelectors } from '../../store/preprint-providers'; +import { + FetchPreprintDetails, + FetchPreprintRequestActions, + FetchPreprintRequests, + FetchPreprintReviewActions, + PreprintSelectors, + ResetPreprintState, +} from '../../store/preprint'; +import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; +import { CreateNewVersion } from '../../store/preprint-stepper'; import { PreprintDetailsComponent } from './preprint-details.component'; @@ -42,59 +51,100 @@ 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 { REVIEW_ACTION_MOCK } from '@testing/mocks/review-action.mock'; -import { ToastServiceMock } from '@testing/mocks/toast.service.mock'; -import { TranslationServiceMock } from '@testing/mocks/translation.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; import { HelpScoutServiceMockFactory } from '@testing/providers/help-scout.service.mock'; import { MetaTagsServiceMockFactory } from '@testing/providers/meta-tags.service.mock'; import { PrerenderReadyServiceMockFactory } from '@testing/providers/prerender-ready.service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('PreprintDetailsComponent', () => { let component: PreprintDetailsComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; - let activatedRouteMock: ReturnType; - let dataciteService: jest.Mocked; - let metaTagsService: jest.Mocked; - let mockCustomDialogService: ReturnType; + let store: Store; + let routerMock: RouterMockType; + let helpScoutServiceMock: jest.Mocked; + let prerenderReadyServiceMock: jest.Mocked; + let dataciteServiceMock: ReturnType; + let metaTagsServiceMock: ReturnType; + let customDialogServiceMock: ReturnType; + let toastService: ToastServiceMockType; - const mockPreprint = PREPRINT_MOCK; const mockProvider = PREPRINT_PROVIDER_DETAILS_MOCK; + const mockPreprint = PREPRINT_MOCK; const mockReviewActions = [REVIEW_ACTION_MOCK]; - const mockWithdrawalRequests = [PREPRINT_REQUEST_MOCK]; - const mockRequestActions = [REVIEW_ACTION_MOCK]; + const mockRequests = [PREPRINT_REQUEST_MOCK]; const mockContributors = [MOCK_CONTRIBUTOR]; - beforeEach(async () => { + const defaultSignals: SignalOverride[] = [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails('osf'), value: mockProvider }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + { selector: PreprintSelectors.getPreprint, value: mockPreprint }, + { selector: PreprintSelectors.isPreprintLoading, value: false }, + { selector: ContributorsSelectors.getBibliographicContributors, value: mockContributors }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, + { selector: PreprintSelectors.getPreprintReviewActions, value: mockReviewActions }, + { selector: PreprintSelectors.arePreprintReviewActionsLoading, value: false }, + { selector: PreprintSelectors.getPreprintRequests, value: mockRequests }, + { selector: PreprintSelectors.arePreprintRequestsLoading, value: false }, + { selector: PreprintSelectors.getPreprintRequestActions, value: mockReviewActions }, + { selector: PreprintSelectors.arePreprintRequestActionsLoading, value: false }, + { selector: PreprintSelectors.hasAdminAccess, value: false }, + { selector: PreprintSelectors.hasWriteAccess, value: true }, + { selector: PreprintSelectors.getPreprintMetrics, value: null }, + { selector: PreprintSelectors.arePreprintMetricsLoading, value: false }, + ]; + + function setup(overrides?: { + selectorOverrides?: SignalOverride[]; + routeParams?: { providerId: string; id: string }; + queryParams?: Record; + routerUrl?: string; + dialogReturnsCloseValue?: boolean; + }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + const routeParams = overrides?.routeParams ?? { providerId: 'osf', id: 'preprint-1' }; + const queryParams = overrides?.queryParams ?? { mode: 'moderator' }; + routerMock = RouterMockBuilder.create() + .withUrl(overrides?.routerUrl ?? '/preprints/osf/preprint-1') .withNavigate(jest.fn().mockResolvedValue(true)) .withNavigateByUrl(jest.fn().mockResolvedValue(true)) .build(); - - activatedRouteMock = ActivatedRouteMockBuilder.create() - .withParams({ providerId: 'osf', id: 'preprint-1' }) - .withQueryParams({ mode: 'moderator' }) + const activatedRouteMock = ActivatedRouteMockBuilder.create() + .withParams(routeParams) + .withQueryParams(queryParams) .build(); - - mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - - dataciteService = { - logIdentifiableView: jest.fn().mockReturnValue(of(void 0)), - logIdentifiableDownload: jest.fn().mockReturnValue(of(void 0)), - } as any; - - metaTagsService = { - updateMetaTags: jest.fn(), - } as any; - - await TestBed.configureTestingModule({ + helpScoutServiceMock = HelpScoutServiceMockFactory(); + prerenderReadyServiceMock = PrerenderReadyServiceMockFactory(); + dataciteServiceMock = DataciteMockFactory(); + metaTagsServiceMock = MetaTagsServiceMockFactory(); + toastService = ToastServiceMock.simple(); + customDialogServiceMock = + overrides?.dialogReturnsCloseValue === false + ? CustomDialogServiceMockBuilder.create() + .withOpen( + jest.fn().mockReturnValue({ + onClose: of(false), + close: jest.fn(), + } as any) + ) + .build() + : CustomDialogServiceMockBuilder.create() + .withOpen( + jest.fn().mockReturnValue({ + onClose: of(true), + close: jest.fn(), + } as any) + ) + .build(); + + TestBed.configureTestingModule({ imports: [ PreprintDetailsComponent, - OSFTestingModule, ...MockComponents( PreprintFileSectionComponent, ShareAndDownloadComponent, @@ -109,263 +159,342 @@ describe('PreprintDetailsComponent', () => { ), ], providers: [ - TranslationServiceMock, - ToastServiceMock, + provideOSFCore(), + MockProvider(ToastService, toastService), MockProvider(Router, routerMock), MockProvider(ActivatedRoute, activatedRouteMock), - MockProvider(DataciteService, dataciteService), - MockProvider(MetaTagsService, metaTagsService), - MockProvider(CustomDialogService, mockCustomDialogService), - provideMockStore({ - signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails('osf'), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintSelectors.isPreprintLoading, - value: false, - }, - { - selector: ContributorsSelectors.getBibliographicContributors, - value: mockContributors, - }, - { - selector: ContributorsSelectors.isBibliographicContributorsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprintReviewActions, - value: mockReviewActions, - }, - { - selector: PreprintSelectors.arePreprintReviewActionsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprintRequests, - value: mockWithdrawalRequests, - }, - { - selector: PreprintSelectors.arePreprintRequestsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprintRequestActions, - value: mockRequestActions, - }, - { - selector: PreprintSelectors.arePreprintRequestActionsLoading, - value: false, - }, - { - selector: PreprintSelectors.hasAdminAccess, - value: false, - }, - { - selector: PreprintSelectors.hasWriteAccess, - value: false, - }, - ], - }), + MockProvider(HelpScoutService, helpScoutServiceMock), + MockProvider(PrerenderReadyService, prerenderReadyServiceMock), + MockProvider(DataciteService, dataciteServiceMock), + MockProvider(MetaTagsService, metaTagsServiceMock), + MockProvider(CustomDialogService, customDialogServiceMock), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintDetailsComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } - it('should return preprint from store', () => { - const preprint = component.preprint(); - expect(preprint).toBe(mockPreprint); + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); }); - it('should return preprint provider from store', () => { - const provider = component.preprintProvider(); - expect(provider).toBe(mockProvider); + it('should dispatch initial fetch actions on creation', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProviderById('osf')); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintDetails('preprint-1')); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintReviewActions()); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintRequests()); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintRequestActions(PREPRINT_REQUEST_MOCK.id)); }); - it('should return loading states from store', () => { - expect(component.isPreprintLoading()).toBe(false); - expect(component.isPreprintProviderLoading()).toBe(false); - expect(component.areReviewActionsLoading()).toBe(false); - expect(component.areWithdrawalRequestsLoading()).toBe(false); - expect(component.areRequestActionsLoading()).toBe(false); + it('should set HelpScout and datacite tracking on initialization', () => { + setup(); + + expect(helpScoutServiceMock.setResourceType).toHaveBeenCalledWith('preprint'); + expect(dataciteServiceMock.logIdentifiableView).toHaveBeenCalledTimes(1); + expect(prerenderReadyServiceMock.setNotReady).toHaveBeenCalled(); }); - it('should return review actions from store', () => { - const actions = component.reviewActions(); - expect(actions).toBe(mockReviewActions); + it('should update meta tags when preprint and contributors are loaded', () => { + setup(); + + expect(metaTagsServiceMock.updateMetaTags).toHaveBeenCalled(); }); - it('should return withdrawal requests from store', () => { - const requests = component.withdrawalRequests(); - expect(requests).toBe(mockWithdrawalRequests); + it('should not fetch moderation actions when not moderator and no permissions', () => { + setup({ + queryParams: {}, + selectorOverrides: [ + { + selector: PreprintProvidersSelectors.getPreprintProviderDetails('osf'), + value: { ...mockProvider, permissions: [] }, + }, + ], + }); + + expect(store.dispatch).not.toHaveBeenCalledWith(new FetchPreprintReviewActions()); + expect(store.dispatch).not.toHaveBeenCalledWith(new FetchPreprintRequests()); }); - it('should return request actions from store', () => { - const actions = component.requestActions(); - expect(actions).toBe(mockRequestActions); + it('should navigate to canonical version id when url id differs', () => { + setup({ routeParams: { providerId: 'osf', id: 'old-id' }, routerUrl: '/preprints/osf/old-id' }); + + expect(routerMock.navigate).toHaveBeenCalledWith(['../', 'preprint-1'], { + relativeTo: expect.anything(), + replaceUrl: true, + queryParamsHandling: 'preserve', + }); }); - it('should return contributors from store', () => { - const contributors = component.contributors(); - expect(contributors).toBe(mockContributors); + it('should navigate to edit page when edit is clicked', () => { + setup(); + + component.editPreprintClicked(); + + expect(routerMock.navigate).toHaveBeenCalledWith(['preprints', 'osf', 'edit', 'preprint-1']); }); - it('should return contributors loading state from store', () => { - const loading = component.areContributorsLoading(); - expect(loading).toBe(false); + it('should create new version and navigate to new version route', () => { + setup(); + jest.spyOn(store, 'selectSnapshot').mockReturnValue({ id: 'new-version-id' } as any); + + component.createNewVersionClicked(); + + expect(store.dispatch).toHaveBeenCalledWith(new CreateNewVersion('preprint-1')); + expect(routerMock.navigate).toHaveBeenCalledWith(['preprints', 'osf', 'new-version', 'new-version-id']); }); - it('should compute latest action correctly', () => { - const latestAction = component.latestAction(); - expect(latestAction).toBe(mockReviewActions[0]); + it('should return early in createNewVersionClicked when preprint id is missing', () => { + setup({ routeParams: { providerId: 'osf', id: '' } }); + (store.dispatch as jest.Mock).mockClear(); + + component.createNewVersionClicked(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(CreateNewVersion)); }); - it('should compute latest withdrawal request correctly', () => { - const latestRequest = component.latestWithdrawalRequest(); - expect(latestRequest).toBe(mockWithdrawalRequests[0]); + it('should show toast error for 409 on create new version', () => { + setup(); + (store.dispatch as jest.Mock).mockImplementation((action: unknown): Observable => { + if (action instanceof CreateNewVersion) { + return throwError( + () => + new HttpErrorResponse({ + status: 409, + error: { errors: [{ detail: 'Version already exists' }] }, + }) + ); + } + + return of(true); + }); + + component.createNewVersionClicked(); + + expect(toastService.showError).toHaveBeenCalledWith('Version already exists'); }); - it('should compute latest request action correctly', () => { - const latestAction = component.latestRequestAction(); - expect(latestAction).toBe(mockRequestActions[0]); + it('should refetch preprint after successful withdraw dialog close', () => { + setup(); + const fetchSpy = jest.spyOn(component, 'fetchPreprint'); + + component.handleWithdrawClicked(); + + expect(customDialogServiceMock.open).toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledWith('preprint-1'); }); - it('should compute isOsfPreprint correctly', () => { - const isOsf = component.isOsfPreprint(); - expect(isOsf).toBe(true); + it('should navigate to pending moderation page on matching 403 error', () => { + setup(); + (store.dispatch as jest.Mock).mockImplementation((action: unknown): Observable => { + if (action instanceof FetchPreprintDetails) { + return throwError( + () => + new HttpErrorResponse({ + status: 403, + error: { + errors: [{ detail: 'This preprint is pending moderation and is not yet publicly available.' }], + }, + }) + ); + } + + return of(true); + }); + + component.fetchPreprint('preprint-1'); + + expect(routerMock.navigate).toHaveBeenCalledWith(['/preprints', 'osf', 'preprint-1', 'pending-moderation']); }); - it('should compute moderation mode correctly', () => { - const moderationMode = component.moderationMode(); - expect(moderationMode).toBe(true); + it('should return early in fetchPreprint when preprint id is missing', () => { + setup(); + const prerenderSpy = prerenderReadyServiceMock.setNotReady as jest.Mock; + prerenderSpy.mockClear(); + (store.dispatch as jest.Mock).mockClear(); + + component.fetchPreprint(''); + + expect(prerenderSpy).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchPreprintDetails)); }); - it('should compute create new version button visibility', () => { - const visible = component.createNewVersionButtonVisible(); - expect(typeof visible).toBe('boolean'); + it('should reset state and provider on destroy in browser', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new ResetPreprintState()); + expect(store.dispatch).toHaveBeenCalledWith(new ClearCurrentProvider()); + expect(helpScoutServiceMock.unsetResourceType).toHaveBeenCalled(); }); - it('should compute edit button visibility', () => { - const visible = component.editButtonVisible(); - expect(typeof visible).toBe('boolean'); + it('should expose expected computed values for default mocks', () => { + setup(); + + expect(component.latestAction()).toBe(mockReviewActions[0]); + expect(component.latestWithdrawalRequest()).toBe(mockRequests[0]); + expect(component.latestRequestAction()).toBe(mockReviewActions[0]); + expect(component.isPendingWithdrawal()).toBe(true); + expect(component.isWithdrawalRejected()).toBe(false); + expect(component.moderationMode()).toBe(true); + expect(component.isOsfPreprint()).toBe(true); }); - it('should compute edit button label', () => { - const label = component.editButtonLabel(); - expect(typeof label).toBe('string'); + it('should mark preprint withdrawable for pending and accepted states', () => { + setup(); + jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Pending } as any); + expect(component['preprintWithdrawableState']()).toBe(true); + + jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Accepted } as any); + expect(component['preprintWithdrawableState']()).toBe(true); }); - it('should compute withdrawal button visibility', () => { - const visible = component.withdrawalButtonVisible(); - expect(typeof visible).toBe('boolean'); + it('should hide edit button when preprint is withdrawn', () => { + setup({ + selectorOverrides: [ + { selector: PreprintSelectors.getPreprint, value: { ...mockPreprint, dateWithdrawn: '2024-01-01' } }, + ], + }); + + expect(component.editButtonVisible()).toBe(false); }); - it('should compute is pending withdrawal', () => { - const pending = component.isPendingWithdrawal(); - expect(typeof pending).toBe('boolean'); + it('should show edit button for latest or initial preprint', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { ...mockPreprint, isLatestVersion: false, reviewsState: ReviewsState.Initial }, + }, + ], + }); + + expect(component.editButtonVisible()).toBe(true); }); - it('should compute is withdrawal rejected', () => { - const rejected = component.isWithdrawalRejected(); - expect(typeof rejected).toBe('boolean'); + it('should show edit button for pending premoderation preprint', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { ...mockPreprint, isLatestVersion: false, reviewsState: ReviewsState.Pending }, + }, + ], + }); + + expect(component.editButtonVisible()).toBe(true); }); - it('should compute moderation status banner visibility', () => { - const visible = component.moderationStatusBannerVisible(); - expect(typeof visible).toBe('boolean'); + it('should show edit-and-resubmit for rejected premoderation preprint with admin access', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { ...mockPreprint, isLatestVersion: false, reviewsState: ReviewsState.Rejected }, + }, + { selector: PreprintSelectors.hasAdminAccess, value: true }, + ], + }); + + expect(component.editButtonVisible()).toBe(true); }); - it('should compute status banner visibility', () => { - const visible = component.statusBannerVisible(); - expect(typeof visible).toBe('boolean'); + it('should hide edit button when none of edit visibility conditions are met', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { ...mockPreprint, isLatestVersion: false, reviewsState: ReviewsState.Rejected }, + }, + ], + }); + + expect(component.editButtonVisible()).toBe(false); }); - it('should navigate to edit page when editPreprintClicked is called', () => { - component.editPreprintClicked(); + it('should return false for statusBannerVisible when provider is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintProvidersSelectors.getPreprintProviderDetails('osf'), value: null }], + }); - expect(routerMock.navigate).toHaveBeenCalledWith(['preprints', 'osf', 'edit', 'preprint-1']); + expect(component.statusBannerVisible()).toBe(false); }); - it('should handle create new version clicked', () => { - expect(() => component.createNewVersionClicked()).not.toThrow(); + it('should return false for statusBannerVisible when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: null }], + }); + + expect(component.statusBannerVisible()).toBe(false); }); - it('should handle preprint with different states', () => { - const acceptedPreprint = { ...mockPreprint, reviewsState: ReviewsState.Accepted }; - jest.spyOn(component, 'preprint').mockReturnValue(acceptedPreprint); + it('should return false for statusBannerVisible when related request data is loading', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.arePreprintRequestsLoading, value: true }], + }); - const withdrawable = component['preprintWithdrawableState'](); - expect(typeof withdrawable).toBe('boolean'); + expect(component.statusBannerVisible()).toBe(false); }); - it('should handle preprint with pending state', () => { - const pendingPreprint = { ...mockPreprint, reviewsState: ReviewsState.Pending }; - jest.spyOn(component, 'preprint').mockReturnValue(pendingPreprint); + it('should return false for isPendingWithdrawal when no withdrawal request exists', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.getPreprintRequests, value: [] }], + }); - const withdrawable = component['preprintWithdrawableState'](); - expect(withdrawable).toBe(true); + expect(component.isPendingWithdrawal()).toBe(false); }); - it('should handle preprint with accepted state', () => { - const acceptedPreprint = { ...mockPreprint, reviewsState: ReviewsState.Accepted }; - jest.spyOn(component, 'preprint').mockReturnValue(acceptedPreprint); + it('should return false for isWithdrawalRejected when no request action exists', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.getPreprintRequestActions, value: [] }], + }); - const withdrawable = component['preprintWithdrawableState'](); - expect(withdrawable).toBe(true); + expect(component.isWithdrawalRejected()).toBe(false); }); - it('should handle preprint with pending state', () => { - const pendingPreprint = { ...mockPreprint, reviewsState: ReviewsState.Pending }; - jest.spyOn(component, 'preprint').mockReturnValue(pendingPreprint); + it('should return false for withdrawalButtonVisible while withdrawal data is loading', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.arePreprintRequestsLoading, value: true }], + }); - const withdrawable = component['preprintWithdrawableState'](); - expect(withdrawable).toBe(true); + expect(component.withdrawalButtonVisible()).toBe(false); }); - it('should handle preprint without write permissions', () => { - const preprintWithoutWrite = { - ...mockPreprint, - currentUserPermissions: [UserPermissions.Read], - }; - jest.spyOn(component, 'preprint').mockReturnValue(preprintWithoutWrite); + it('should return early in editPreprintClicked when route ids are missing', () => { + setup({ routeParams: { providerId: '', id: '' } }); + (routerMock.navigate as jest.Mock).mockClear(); + + component.editPreprintClicked(); - const hasAccess = component['hasWriteAccess'](); - expect(hasAccess).toBe(false); + expect(routerMock.navigate).not.toHaveBeenCalled(); }); }); -describe('PreprintDetailsComponent SSR Tests', () => { +describe('PreprintDetailsComponent SSR', () => { let component: PreprintDetailsComponent; let fixture: ComponentFixture; - let httpMock: HttpTestingController; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; let store: Store; - - const mockPreprint = PREPRINT_MOCK; const mockProvider = PREPRINT_PROVIDER_DETAILS_MOCK; + const mockPreprint = PREPRINT_MOCK; const mockContributors = [MOCK_CONTRIBUTOR]; - beforeEach(async () => { - mockRouter = RouterMockBuilder.create().build(); - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ providerId: 'osf', id: 'preprint-1' }).build(); + function setup() { + const routerMock = RouterMockBuilder.create().build(); + const activatedRouteMock = ActivatedRouteMockBuilder.create() + .withParams({ providerId: 'osf', id: 'preprint-1' }) + .build(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ PreprintDetailsComponent, - OSFTestingModule, ...MockComponents( PreprintFileSectionComponent, ShareAndDownloadComponent, @@ -381,118 +510,64 @@ describe('PreprintDetailsComponent SSR Tests', () => { ], providers: [ provideServerRendering(), - { provide: PLATFORM_ID, useValue: 'server' }, - MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(Router, mockRouter), - MockProvider(CustomDialogService, CustomDialogServiceMockBuilder.create().build()), + provideOSFCore(), + MockProvider(PLATFORM_ID, 'server'), + MockProvider(ToastService, ToastServiceMock.simple()), + MockProvider(ActivatedRoute, activatedRouteMock), + MockProvider(Router, routerMock), + MockProvider(CustomDialogService, CustomDialogServiceMockBuilder.create().withDefaultOpen().build()), MockProvider(DataciteService, DataciteMockFactory()), MockProvider(MetaTagsService, MetaTagsServiceMockFactory()), MockProvider(PrerenderReadyService, PrerenderReadyServiceMockFactory()), MockProvider(HelpScoutService, HelpScoutServiceMockFactory()), - TranslationServiceMock, - ToastServiceMock, provideMockStore({ signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails('osf'), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintSelectors.isPreprintLoading, - value: false, - }, - { - selector: ContributorsSelectors.getBibliographicContributors, - value: mockContributors, - }, - { - selector: ContributorsSelectors.isBibliographicContributorsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprintReviewActions, - value: [], - }, - { - selector: PreprintSelectors.arePreprintReviewActionsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprintRequests, - value: [], - }, - { - selector: PreprintSelectors.arePreprintRequestsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprintRequestActions, - value: [], - }, - { - selector: PreprintSelectors.arePreprintRequestActionsLoading, - value: false, - }, - { - selector: PreprintSelectors.hasAdminAccess, - value: false, - }, - { - selector: PreprintSelectors.hasWriteAccess, - value: false, - }, - { - selector: PreprintSelectors.getPreprintMetrics, - value: null, - }, - { - selector: PreprintSelectors.arePreprintMetricsLoading, - value: false, - }, + { selector: PreprintProvidersSelectors.getPreprintProviderDetails('osf'), value: mockProvider }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + { selector: PreprintSelectors.getPreprint, value: mockPreprint }, + { selector: PreprintSelectors.isPreprintLoading, value: false }, + { selector: ContributorsSelectors.getBibliographicContributors, value: mockContributors }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, + { selector: PreprintSelectors.getPreprintReviewActions, value: [] }, + { selector: PreprintSelectors.arePreprintReviewActionsLoading, value: false }, + { selector: PreprintSelectors.getPreprintRequests, value: [] }, + { selector: PreprintSelectors.arePreprintRequestsLoading, value: false }, + { selector: PreprintSelectors.getPreprintRequestActions, value: [] }, + { selector: PreprintSelectors.arePreprintRequestActionsLoading, value: false }, + { selector: PreprintSelectors.hasAdminAccess, value: false }, + { selector: PreprintSelectors.hasWriteAccess, value: false }, + { selector: PreprintSelectors.getPreprintMetrics, value: null }, + { selector: PreprintSelectors.arePreprintMetricsLoading, value: false }, ], }), ], - }).compileComponents(); + }); - httpMock = TestBed.inject(HttpTestingController); store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintDetailsComponent); component = fixture.componentInstance; - }); + } - it('should render PreprintDetailsComponent server-side without errors', () => { - expect(() => { - fixture.detectChanges(); - }).not.toThrow(); - expect(component).toBeTruthy(); + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); }); - it('should not access browser-only APIs during SSR', () => { - const platformId = TestBed.inject(PLATFORM_ID); - expect(platformId).toBe('server'); - fixture.detectChanges(); + it('should render without browser-only errors in SSR', () => { + setup(); + + expect(() => fixture.detectChanges()).not.toThrow(); expect(component).toBeTruthy(); }); - it('should not call browser-only actions in ngOnDestroy during SSR', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch'); - + it('should skip reset dispatches in ngOnDestroy on SSR', () => { + setup(); fixture.detectChanges(); + const dispatchSpy = jest.spyOn(store, 'dispatch'); dispatchSpy.mockClear(); + component.ngOnDestroy(); expect(dispatchSpy).not.toHaveBeenCalled(); }); - - afterEach(() => { - httpMock.verify(); - }); }); diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts index 0f02c16dc..a904cff99 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts @@ -5,7 +5,7 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; -import { catchError, EMPTY, filter, map, of } from 'rxjs'; +import { catchError, EMPTY, filter, map } from 'rxjs'; import { DatePipe, isPlatformBrowser } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; @@ -31,7 +31,6 @@ import { ClearCurrentProvider } from '@core/store/provider'; import { UserSelectors } from '@core/store/user'; import { ReviewPermissions } from '@osf/shared/enums/review-permissions.enum'; import { pathJoin } from '@osf/shared/helpers/path-join.helper'; -import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; @@ -66,21 +65,20 @@ import { CreateNewVersion, PreprintStepperSelectors } from '../../store/preprint @Component({ selector: 'osf-preprint-details', imports: [ + Button, Skeleton, PreprintFileSectionComponent, - Button, ShareAndDownloadComponent, GeneralInformationComponent, AdditionalInfoComponent, StatusBannerComponent, - TranslatePipe, PreprintTombstoneComponent, PreprintWarningBannerComponent, ModerationStatusBannerComponent, PreprintMakeDecisionComponent, PreprintMetricsInfoComponent, RouterLink, - FixSpecialCharPipe, + TranslatePipe, ], templateUrl: './preprint-details.component.html', styleUrl: './preprint-details.component.scss', @@ -103,13 +101,13 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { private readonly dataciteService = inject(DataciteService); private readonly prerenderReady = inject(PrerenderReadyService); private readonly platformId = inject(PLATFORM_ID); - private readonly isBrowser = isPlatformBrowser(this.platformId); - private readonly environment = inject(ENVIRONMENT); - private preprintId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); + private readonly isBrowser = isPlatformBrowser(this.platformId); + + private readonly preprintId = toSignal(this.route.params.pipe(map((params) => params['id']))); - private actions = createDispatchMap({ + private readonly actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, resetState: ResetPreprintState, fetchPreprintById: FetchPreprintDetails, @@ -120,12 +118,12 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { clearCurrentProvider: ClearCurrentProvider, }); - providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); + readonly providerId = toSignal(this.route.params.pipe(map((params) => params['providerId']))); currentUser = select(UserSelectors.getCurrentUser); preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); preprint = select(PreprintSelectors.getPreprint); - preprint$ = toObservable(select(PreprintSelectors.getPreprint)); + preprint$ = toObservable(this.preprint); isPreprintLoading = select(PreprintSelectors.isPreprintLoading); contributors = select(ContributorsSelectors.getBibliographicContributors); areContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); @@ -150,26 +148,17 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { latestAction = computed(() => { const actions = this.reviewActions(); - - if (actions.length < 1) return null; - - return actions[0]; + return actions.length > 0 ? actions[0] : null; }); latestWithdrawalRequest = computed(() => { const requests = this.withdrawalRequests(); - - if (requests.length < 1) return null; - - return requests[0]; + return requests.length > 0 ? requests[0] : null; }); latestRequestAction = computed(() => { const actions = this.requestActions(); - - if (actions.length < 1) return null; - - return actions[0]; + return actions.length > 0 ? actions[0] : null; }); constructor() { @@ -219,6 +208,7 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { if (preprint.isLatestVersion || preprint.reviewsState === ReviewsState.Initial) { return true; } + if (providerIsPremod) { if (preprint.reviewsState === ReviewsState.Pending) { return true; @@ -228,6 +218,7 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { return true; } } + return false; }); @@ -250,7 +241,7 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { isWithdrawalRejected = computed(() => { const latestRequestActions = this.latestRequestAction(); if (!latestRequestActions) return false; - return latestRequestActions?.trigger === 'reject'; + return latestRequestActions.trigger === 'reject'; }); withdrawalButtonVisible = computed(() => { @@ -300,14 +291,22 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { ); }); - ngOnInit() { - this.actions.getPreprintProviderById(this.providerId()); - this.fetchPreprint(this.preprintId()); + ngOnInit(): void { + const providerId = this.providerId(); + const preprintId = this.preprintId(); + + if (providerId) { + this.actions.getPreprintProviderById(providerId); + } + + if (preprintId) { + this.fetchPreprint(preprintId); + } this.dataciteService.logIdentifiableView(this.preprint$).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); } - ngOnDestroy() { + ngOnDestroy(): void { if (this.isBrowser) { this.actions.resetState(); this.actions.clearCurrentProvider(); @@ -316,7 +315,7 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { this.helpScoutService.unsetResourceType(); } - handleWithdrawClicked() { + handleWithdrawClicked(): void { this.customDialogService .open(PreprintWithdrawDialogComponent, { header: this.translateService.instant('preprints.details.withdrawDialog.title', { @@ -329,20 +328,29 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { }, }) .onClose.pipe(takeUntilDestroyed(this.destroyRef), filter(Boolean)) - .subscribe({ - next: () => { - this.fetchPreprint(this.preprintId()); - }, - }); + .subscribe(() => this.fetchPreprint(this.preprintId())); } - editPreprintClicked() { - this.router.navigate(['preprints', this.providerId(), 'edit', this.preprintId()]); + editPreprintClicked(): void { + const providerId = this.providerId(); + const preprintId = this.preprintId(); + + if (!providerId || !preprintId) { + return; + } + + this.router.navigate(['preprints', providerId, 'edit', preprintId]); } - createNewVersionClicked() { + createNewVersionClicked(): void { + const preprintId = this.preprintId(); + + if (!preprintId) { + return; + } + this.actions - .createNewVersion(this.preprintId()) + .createNewVersion(preprintId) .pipe( catchError((e) => { if (e instanceof HttpErrorResponse && e.status === 409) { @@ -355,12 +363,18 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { .subscribe({ complete: () => { const newVersionPreprint = this.store.selectSnapshot(PreprintStepperSelectors.getPreprint); - this.router.navigate(['preprints', this.providerId(), 'new-version', newVersionPreprint!.id]); + if (newVersionPreprint?.id) { + this.router.navigate(['preprints', this.providerId(), 'new-version', newVersionPreprint.id]); + } }, }); } - fetchPreprint(preprintId: string) { + fetchPreprint(preprintId: string): void { + if (!preprintId) { + return; + } + this.prerenderReady.setNotReady(); this.actions.fetchPreprintById(preprintId).subscribe({ @@ -420,7 +434,11 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { private checkAndSetVersionToTheUrl() { const currentUrl = this.router.url; - const newPreprintId = this.preprint()!.id; + const newPreprintId = this.preprint()?.id; + + if (!newPreprintId) { + return; + } const urlSegments = currentUrl.split('/'); const preprintIdFromUrl = urlSegments[urlSegments.length - 1]; diff --git a/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.spec.ts b/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.spec.ts index 27b3a577d..22bab4f45 100644 --- a/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.spec.ts @@ -2,16 +2,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PreprintPendingModerationComponent } from './preprint-pending-moderation.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('PreprintPendingModerationComponent', () => { let component: PreprintPendingModerationComponent; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PreprintPendingModerationComponent, OSFTestingModule], - }).compileComponents(); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [PreprintPendingModerationComponent], + providers: [provideOSFCore()], + }); fixture = TestBed.createComponent(PreprintPendingModerationComponent); component = fixture.componentInstance; diff --git a/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.ts b/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.ts index c6e482486..97de0f1bf 100644 --- a/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.ts +++ b/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.ts @@ -4,9 +4,9 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; @Component({ selector: 'osf-preprint-pending-moderation', + imports: [TranslatePipe], templateUrl: './preprint-pending-moderation.component.html', styleUrl: './preprint-pending-moderation.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TranslatePipe], }) export class PreprintPendingModerationComponent {} diff --git a/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.spec.ts b/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.spec.ts index 21ccc8848..41ec1e9ef 100644 --- a/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.spec.ts @@ -1,135 +1,136 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormControl } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { BrandService } from '@osf/shared/services/brand.service'; import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; import { HeaderStyleService } from '@osf/shared/services/header-style.service'; +import { SetDefaultFilterValue, SetResourceType } from '@osf/shared/stores/global-search'; import { PreprintProviderHeroComponent } from '../../components'; import { PreprintProviderDetails } from '../../models'; -import { PreprintProvidersSelectors } from '../../store/preprint-providers'; +import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; import { PreprintProviderDiscoverComponent } from './preprint-provider-discover.component'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { BrandServiceMock, BrandServiceMockType } from '@testing/providers/brand-service.mock'; +import { BrowserTabServiceMock, BrowserTabServiceMockType } from '@testing/providers/browser-tab-service.mock'; +import { HeaderStyleServiceMock, HeaderStyleServiceMockType } from '@testing/providers/header-style-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; describe('PreprintProviderDiscoverComponent', () => { let component: PreprintProviderDiscoverComponent; let fixture: ComponentFixture; - let routeMock: ReturnType; + let store: Store; + let brandServiceMock: BrandServiceMockType; + let headerStyleMock: HeaderStyleServiceMockType; + let browserTabMock: BrowserTabServiceMockType; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; const mockProviderId = 'osf'; - beforeEach(async () => { - routeMock = ActivatedRouteMockBuilder.create() - .withParams({ providerId: mockProviderId }) - .withQueryParams({}) - .build(); + const defaultSignals: SignalOverride[] = [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: mockProvider }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + ]; + + function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + const routeMock = ActivatedRouteMockBuilder.create().withParams({ providerId: mockProviderId }).build(); + brandServiceMock = BrandServiceMock.simple(); + headerStyleMock = HeaderStyleServiceMock.simple(); + browserTabMock = BrowserTabServiceMock.simple(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ PreprintProviderDiscoverComponent, - OSFTestingModule, ...MockComponents(PreprintProviderHeroComponent, GlobalSearchComponent), ], providers: [ - MockProvider(BrandService), - MockProvider(BrowserTabService), - MockProvider(HeaderStyleService), + provideOSFCore(), MockProvider(ActivatedRoute, routeMock), - provideMockStore({ - signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - ], - }), + MockProvider(BrandService, brandServiceMock), + MockProvider(HeaderStyleService, headerStyleMock), + MockProvider(BrowserTabService, browserTabMock), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintProviderDiscoverComponent); component = fixture.componentInstance; fixture.detectChanges(); + } + + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); }); it('should initialize with correct default values', () => { + setup(); + expect(component.providerId).toBe(mockProviderId); expect(component.classes).toBe('flex-1 flex flex-column w-full h-full'); - expect(component.searchControl).toBeDefined(); expect(component.searchControl.value).toBe(''); + expect(component.defaultSearchFiltersInitialized()).toBe(true); }); - it('should return preprint provider from store', () => { - const provider = component.preprintProvider(); - expect(provider).toBe(mockProvider); - }); + it('should dispatch provider fetch on creation', () => { + setup(); - it('should return loading state from store', () => { - const loading = component.isPreprintProviderLoading(); - expect(loading).toBe(false); + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProviderById(mockProviderId)); }); - it('should initialize search control correctly', () => { - expect(component.searchControl).toBeDefined(); - expect(component.searchControl.value).toBe(''); - }); + it('should initialize global search filters when provider is available', () => { + setup(); - it('should handle search control value changes', () => { - const testValue = 'test search'; - component.searchControl.setValue(testValue); - expect(component.searchControl.value).toBe(testValue); + expect(store.dispatch).toHaveBeenCalledWith(new SetDefaultFilterValue('publisher', mockProvider.iri)); + expect(store.dispatch).toHaveBeenCalledWith(new SetResourceType(ResourceType.Preprint)); + expect(component.defaultSearchFiltersInitialized()).toBe(true); }); - it('should initialize signals correctly', () => { - expect(component.preprintProvider).toBeDefined(); - expect(component.isPreprintProviderLoading).toBeDefined(); - }); + it('should not initialize global search filters when provider is unavailable', () => { + setup({ + selectorOverrides: [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: null }, + ], + }); - it('should handle provider data correctly', () => { - const provider = component.preprintProvider(); - expect(provider).toBe(mockProvider); - expect(provider?.id).toBe(mockProvider.id); - expect(provider?.name).toBe(mockProvider.name); - }); + const dispatchedActions = (store.dispatch as jest.Mock).mock.calls.map(([action]) => action); - it('should handle loading state correctly', () => { - const loading = component.isPreprintProviderLoading(); - expect(typeof loading).toBe('boolean'); - expect(loading).toBe(false); + expect(dispatchedActions.some((action) => action instanceof SetDefaultFilterValue)).toBe(false); + expect(dispatchedActions.some((action) => action instanceof SetResourceType)).toBe(false); + expect(component.defaultSearchFiltersInitialized()).toBe(false); }); - it('should handle search control initialization', () => { - expect(component.searchControl).toBeInstanceOf(FormControl); - expect(component.searchControl.value).toBe(''); - }); + it('should apply branding when provider is available', () => { + setup(); - it('should handle search control updates', () => { - const newValue = 'new search term'; - component.searchControl.setValue(newValue); - expect(component.searchControl.value).toBe(newValue); + expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(mockProvider.brand); + expect(headerStyleMock.applyHeaderStyles).toHaveBeenCalledWith( + mockProvider.brand.primaryColor, + mockProvider.brand.secondaryColor, + mockProvider.brand.heroBackgroundImageUrl + ); + expect(browserTabMock.updateTabStyles).toHaveBeenCalledWith(mockProvider.faviconUrl, mockProvider.name); }); - it('should handle search control reset', () => { - component.searchControl.setValue('some value'); - component.searchControl.setValue(''); - expect(component.searchControl.value).toBe(''); - }); + it('should reset styles on destroy', () => { + setup(); + + component.ngOnDestroy(); - it('should handle search control with null value', () => { - component.searchControl.setValue(null); - expect(component.searchControl.value).toBe(null); + expect(headerStyleMock.resetToDefaults).toHaveBeenCalled(); + expect(brandServiceMock.resetBranding).toHaveBeenCalled(); + expect(browserTabMock.resetToDefaults).toHaveBeenCalled(); }); }); diff --git a/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.ts b/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.ts index 577947d2f..ff52d233e 100644 --- a/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.ts +++ b/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.ts @@ -1,6 +1,6 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { ChangeDetectionStrategy, Component, HostBinding, inject, OnDestroy, OnInit, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, HostBinding, inject, OnDestroy, signal } from '@angular/core'; import { FormControl } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; @@ -21,7 +21,7 @@ import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store styleUrl: './preprint-provider-discover.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PreprintProviderDiscoverComponent implements OnInit, OnDestroy { +export class PreprintProviderDiscoverComponent implements OnDestroy { @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; private readonly activatedRoute = inject(ActivatedRoute); @@ -29,39 +29,43 @@ export class PreprintProviderDiscoverComponent implements OnInit, OnDestroy { private readonly headerStyleHelper = inject(HeaderStyleService); private readonly browserTabHelper = inject(BrowserTabService); - private actions = createDispatchMap({ + private readonly actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, setDefaultFilterValue: SetDefaultFilterValue, setResourceType: SetResourceType, }); - providerId = this.activatedRoute.snapshot.params['providerId']; + readonly providerId = this.activatedRoute.snapshot.params['providerId']; preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId)); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); - searchControl = new FormControl(''); + searchControl = new FormControl('', { nonNullable: true }); defaultSearchFiltersInitialized = signal(false); - ngOnInit() { - this.actions.getPreprintProviderById(this.providerId).subscribe({ - next: () => { - const provider = this.preprintProvider(); + constructor() { + this.actions.getPreprintProviderById(this.providerId); - if (provider) { - this.actions.setDefaultFilterValue('publisher', provider.iri); - this.actions.setResourceType(ResourceType.Preprint); - this.defaultSearchFiltersInitialized.set(true); + effect(() => { + const provider = this.preprintProvider(); - this.brandService.applyBranding(provider.brand); - this.headerStyleHelper.applyHeaderStyles( - provider.brand.primaryColor, - provider.brand.secondaryColor, - provider.brand.heroBackgroundImageUrl - ); - this.browserTabHelper.updateTabStyles(provider.faviconUrl, provider.name); - } - }, + if (!provider) { + return; + } + + if (!this.defaultSearchFiltersInitialized()) { + this.actions.setDefaultFilterValue('publisher', provider.iri); + this.actions.setResourceType(ResourceType.Preprint); + this.defaultSearchFiltersInitialized.set(true); + } + + this.brandService.applyBranding(provider.brand); + this.headerStyleHelper.applyHeaderStyles( + provider.brand.primaryColor, + provider.brand.secondaryColor, + provider.brand.heroBackgroundImageUrl + ); + this.browserTabHelper.updateTabStyles(provider.faviconUrl, provider.name); }); } diff --git a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.spec.ts b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.spec.ts index 8c307f9ab..cff780ff5 100644 --- a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.spec.ts @@ -1,3 +1,5 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -14,38 +16,57 @@ import { PreprintProviderHeroComponent, } from '../../components'; import { PreprintProviderDetails } from '../../models'; -import { PreprintProvidersSelectors } from '../../store/preprint-providers'; +import { + GetHighlightedSubjectsByProviderId, + GetPreprintProviderById, + PreprintProvidersSelectors, +} from '../../store/preprint-providers'; import { PreprintProviderOverviewComponent } from './preprint-provider-overview.component'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; import { SUBJECTS_MOCK } from '@testing/mocks/subject.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { BrandServiceMock, BrandServiceMockType } from '@testing/providers/brand-service.mock'; +import { BrowserTabServiceMock, BrowserTabServiceMockType } from '@testing/providers/browser-tab-service.mock'; +import { HeaderStyleServiceMock, HeaderStyleServiceMockType } from '@testing/providers/header-style-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; describe('PreprintProviderOverviewComponent', () => { let component: PreprintProviderOverviewComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; + let store: Store; + let routerMock: RouterMockType; let routeMock: ReturnType; + let brandServiceMock: BrandServiceMockType; + let headerStyleMock: HeaderStyleServiceMockType; + let browserTabMock: BrowserTabServiceMockType; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; const mockSubjects = SUBJECTS_MOCK; const mockProviderId = 'osf'; - beforeEach(async () => { + const defaultSignals: SignalOverride[] = [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: mockProvider }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + { selector: PreprintProvidersSelectors.getHighlightedSubjectsForProvider, value: mockSubjects }, + { selector: PreprintProvidersSelectors.areSubjectsLoading, value: false }, + ]; + + function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); - routeMock = ActivatedRouteMockBuilder.create() - .withParams({ providerId: mockProviderId }) - .withQueryParams({}) - .build(); + routeMock = ActivatedRouteMockBuilder.create().withParams({ providerId: mockProviderId }).build(); + brandServiceMock = BrandServiceMock.simple(); + headerStyleMock = HeaderStyleServiceMock.simple(); + browserTabMock = BrowserTabServiceMock.simple(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ PreprintProviderOverviewComponent, - OSFTestingModule, ...MockComponents( PreprintProviderHeroComponent, PreprintProviderFooterComponent, @@ -54,135 +75,83 @@ describe('PreprintProviderOverviewComponent', () => { ), ], providers: [ - MockProvider(BrandService), - MockProvider(BrowserTabService), - MockProvider(HeaderStyleService), + provideOSFCore(), MockProvider(Router, routerMock), MockProvider(ActivatedRoute, routeMock), - provideMockStore({ - signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - { - selector: PreprintProvidersSelectors.getHighlightedSubjectsForProvider, - value: mockSubjects, - }, - { - selector: PreprintProvidersSelectors.areSubjectsLoading, - value: false, - }, - ], - }), + MockProvider(BrandService, brandServiceMock), + MockProvider(HeaderStyleService, headerStyleMock), + MockProvider(BrowserTabService, browserTabMock), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintProviderOverviewComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } - it('should initialize with correct default values', () => { - expect(component.preprintProvider).toBeDefined(); - expect(component.isPreprintProviderLoading).toBeDefined(); - expect(component.highlightedSubjectsByProviderId).toBeDefined(); - expect(component.areSubjectsLoading).toBeDefined(); + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); }); - it('should return preprint provider from store', () => { - const provider = component.preprintProvider(); - expect(provider).toBe(mockProvider); - }); + it('should dispatch initial actions on creation', () => { + setup(); - it('should return loading state from store', () => { - const loading = component.isPreprintProviderLoading(); - expect(loading).toBe(false); + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProviderById(mockProviderId)); + expect(store.dispatch).toHaveBeenCalledWith(new GetHighlightedSubjectsByProviderId(mockProviderId)); }); - it('should return highlighted subjects from store', () => { - const subjects = component.highlightedSubjectsByProviderId(); - expect(subjects).toBe(mockSubjects); - }); + it('should apply branding when provider is available', () => { + setup(); - it('should return subjects loading state from store', () => { - const loading = component.areSubjectsLoading(); - expect(loading).toBe(false); + expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(mockProvider.brand); + expect(headerStyleMock.applyHeaderStyles).toHaveBeenCalledWith( + mockProvider.brand.primaryColor, + mockProvider.brand.secondaryColor, + mockProvider.brand.heroBackgroundImageUrl + ); + expect(browserTabMock.updateTabStyles).toHaveBeenCalledWith(mockProvider.faviconUrl, mockProvider.name); }); - it('should handle provider data correctly', () => { - const provider = component.preprintProvider(); - expect(provider).toBe(mockProvider); - expect(provider?.id).toBe(mockProvider.id); - expect(provider?.name).toBe(mockProvider.name); - }); - - it('should handle subjects data correctly', () => { - const subjects = component.highlightedSubjectsByProviderId(); - expect(subjects).toBe(mockSubjects); - expect(Array.isArray(subjects)).toBe(true); - }); + it('should reset branding and header styles on destroy', () => { + setup(); - it('should handle loading states correctly', () => { - const providerLoading = component.isPreprintProviderLoading(); - const subjectsLoading = component.areSubjectsLoading(); + component.ngOnDestroy(); - expect(typeof providerLoading).toBe('boolean'); - expect(typeof subjectsLoading).toBe('boolean'); - expect(providerLoading).toBe(false); - expect(subjectsLoading).toBe(false); + expect(headerStyleMock.resetToDefaults).toHaveBeenCalled(); + expect(brandServiceMock.resetBranding).toHaveBeenCalled(); + expect(browserTabMock.resetToDefaults).toHaveBeenCalled(); }); it('should navigate to discover page with search value', () => { + setup(); const searchValue = 'test search'; + component.redirectToDiscoverPageWithValue(searchValue); expect(routerMock.navigate).toHaveBeenCalledWith(['discover'], { - relativeTo: expect.any(Object), + relativeTo: expect.anything(), queryParams: { search: searchValue }, }); }); it('should navigate to discover page with empty search value', () => { + setup(); const searchValue = ''; - component.redirectToDiscoverPageWithValue(searchValue); - - expect(routerMock.navigate).toHaveBeenCalledWith(['discover'], { - relativeTo: expect.any(Object), - queryParams: { search: searchValue }, - }); - }); - it('should navigate to discover page with null search value', () => { - const searchValue = null as any; component.redirectToDiscoverPageWithValue(searchValue); expect(routerMock.navigate).toHaveBeenCalledWith(['discover'], { - relativeTo: expect.any(Object), + relativeTo: expect.anything(), queryParams: { search: searchValue }, }); }); - it('should initialize signals correctly', () => { - expect(component.preprintProvider).toBeDefined(); - expect(component.isPreprintProviderLoading).toBeDefined(); - expect(component.highlightedSubjectsByProviderId).toBeDefined(); - expect(component.areSubjectsLoading).toBeDefined(); - }); - - it('should handle provider data with null values', () => { - const provider = component.preprintProvider(); - expect(provider).toBeDefined(); - expect(provider).toBe(mockProvider); - }); + it('should expose highlighted subjects from store', () => { + setup(); - it('should handle subjects data with empty array', () => { - const subjects = component.highlightedSubjectsByProviderId(); - expect(subjects).toBeDefined(); - expect(Array.isArray(subjects)).toBe(true); + expect(component.highlightedSubjectsByProviderId()).toBe(mockSubjects); }); }); diff --git a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.ts b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.ts index c38ab13f4..5eb4560c2 100644 --- a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.ts +++ b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.ts @@ -1,8 +1,8 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { map, of } from 'rxjs'; +import { map } from 'rxjs'; -import { ChangeDetectionStrategy, Component, effect, inject, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, inject, OnDestroy } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; @@ -23,7 +23,7 @@ import { } from '../../store/preprint-providers'; @Component({ - selector: 'osf-provider-overview', + selector: 'osf-preprint-provider-overview', imports: [ AdvisoryBoardComponent, BrowseBySubjectsComponent, @@ -34,15 +34,16 @@ import { styleUrl: './preprint-provider-overview.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PreprintProviderOverviewComponent implements OnInit, OnDestroy { +export class PreprintProviderOverviewComponent implements OnDestroy { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly brandService = inject(BrandService); private readonly headerStyleHelper = inject(HeaderStyleService); private readonly browserTabHelper = inject(BrowserTabService); - private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); - private actions = createDispatchMap({ + private readonly providerId = toSignal(this.route.params.pipe(map((params) => params['providerId']))); + + private readonly actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, getHighlightedSubjectsByProviderId: GetHighlightedSubjectsByProviderId, }); @@ -53,6 +54,9 @@ export class PreprintProviderOverviewComponent implements OnInit, OnDestroy { areSubjectsLoading = select(PreprintProvidersSelectors.areSubjectsLoading); constructor() { + this.actions.getPreprintProviderById(this.providerId()); + this.actions.getHighlightedSubjectsByProviderId(this.providerId()); + effect(() => { const provider = this.preprintProvider(); @@ -68,18 +72,13 @@ export class PreprintProviderOverviewComponent implements OnInit, OnDestroy { }); } - ngOnInit() { - this.actions.getPreprintProviderById(this.providerId()); - this.actions.getHighlightedSubjectsByProviderId(this.providerId()); - } - ngOnDestroy() { this.headerStyleHelper.resetToDefaults(); this.brandService.resetBranding(); this.browserTabHelper.resetToDefaults(); } - redirectToDiscoverPageWithValue(searchValue: string) { + redirectToDiscoverPageWithValue(searchValue: string): void { this.router.navigate(['discover'], { relativeTo: this.route, queryParams: { search: searchValue }, diff --git a/src/app/features/preprints/pages/landing/preprints-landing.component.html b/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.html similarity index 79% rename from src/app/features/preprints/pages/landing/preprints-landing.component.html rename to src/app/features/preprints/pages/preprints-landing/preprints-landing.component.html index 5d1517fe1..c1090b5d0 100644 --- a/src/app/features/preprints/pages/landing/preprints-landing.component.html +++ b/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.html @@ -1,29 +1,31 @@ +@let providerDetails = provider(); +

{{ 'preprints.title' | translate }}

- @if (isPreprintProviderLoading()) { + @if (isProviderLoading()) { } @else { }
- @if (isPreprintProviderLoading()) { + @if (isProviderLoading()) {
} @else { -
+
} {{ 'preprints.poweredBy' | translate }}
@@ -32,32 +34,32 @@

{{ 'preprints.title' | translate }}

class="w-full" [control]="searchControl" [placeholder]="'preprints.searchPlaceholder' | translate: { preprintWord: 'preprint' } | titlecase" - (triggerSearch)="redirectToSearchPageWithValue()" + (triggerSearch)="submitSearch()" /> - @if (isPreprintProviderLoading()) { + @if (isProviderLoading()) { - } @else if (osfPreprintProvider()!.examplePreprintId) { + } @else if (providerDetails?.examplePreprintId) { {{ 'preprints.showExample' | translate }} }
diff --git a/src/app/features/preprints/pages/landing/preprints-landing.component.scss b/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.scss similarity index 100% rename from src/app/features/preprints/pages/landing/preprints-landing.component.scss rename to src/app/features/preprints/pages/preprints-landing/preprints-landing.component.scss diff --git a/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.spec.ts b/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.spec.ts new file mode 100644 index 000000000..d03a43b0b --- /dev/null +++ b/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.spec.ts @@ -0,0 +1,144 @@ +import { Store } from '@ngxs/store'; + +import { MockComponents, MockPipe, MockProvider } from 'ng-mocks'; + +import { TitleCasePipe } from '@angular/common'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { BrandService } from '@osf/shared/services/brand.service'; + +import { AdvisoryBoardComponent, BrowseBySubjectsComponent, PreprintServicesComponent } from '../../components'; +import { PreprintProviderDetails } from '../../models'; +import { + GetHighlightedSubjectsByProviderId, + GetPreprintProviderById, + GetPreprintProvidersToAdvertise, + PreprintProvidersSelectors, +} from '../../store/preprint-providers'; + +import { PreprintsLandingComponent } from './preprints-landing.component'; + +import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; +import { PREPRINT_PROVIDER_SHORT_INFO_MOCK } from '@testing/mocks/preprint-provider-short-info.mock'; +import { SUBJECTS_MOCK } from '@testing/mocks/subject.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { BrandServiceMock, BrandServiceMockType } from '@testing/providers/brand-service.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; + +describe('PreprintsLandingComponent', () => { + let component: PreprintsLandingComponent; + let fixture: ComponentFixture; + let store: Store; + let routerMock: RouterMockType; + let brandServiceMock: BrandServiceMockType; + + const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; + const mockProvidersToAdvertise = [PREPRINT_PROVIDER_SHORT_INFO_MOCK]; + const mockHighlightedSubjects = SUBJECTS_MOCK; + const mockDefaultProvider = 'osf'; + + const defaultSignals: SignalOverride[] = [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockDefaultProvider), value: mockProvider }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + { selector: PreprintProvidersSelectors.getPreprintProvidersToAdvertise, value: mockProvidersToAdvertise }, + { selector: PreprintProvidersSelectors.getHighlightedSubjectsForProvider, value: mockHighlightedSubjects }, + { selector: PreprintProvidersSelectors.areSubjectsLoading, value: false }, + ]; + + function setup() { + routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); + brandServiceMock = BrandServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [ + PreprintsLandingComponent, + ...MockComponents( + SearchInputComponent, + AdvisoryBoardComponent, + PreprintServicesComponent, + BrowseBySubjectsComponent + ), + MockPipe(TitleCasePipe), + ], + providers: [ + provideOSFCore(), + MockProvider(BrandService, brandServiceMock), + MockProvider(Router, routerMock), + provideMockStore({ signals: defaultSignals }), + ], + }); + + store = TestBed.inject(Store); + fixture = TestBed.createComponent(PreprintsLandingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); + }); + + it('should initialize with correct default values', () => { + setup(); + + expect(component.searchControl.value).toBe(''); + expect(component.supportEmail).toBeDefined(); + expect(component.classes).toBe('flex-1 flex flex-column w-full h-full'); + }); + + it('should dispatch initial actions on creation', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProviderById(mockDefaultProvider)); + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProvidersToAdvertise()); + expect(store.dispatch).toHaveBeenCalledWith(new GetHighlightedSubjectsByProviderId(mockDefaultProvider)); + }); + + it('should apply branding when provider is available', () => { + setup(); + + expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(mockProvider.brand); + }); + + it('should reset branding on destroy', () => { + setup(); + + component.ngOnDestroy(); + + expect(brandServiceMock.resetBranding).toHaveBeenCalled(); + }); + + it('should navigate to search page with search value', () => { + setup(); + component.searchControl.setValue('test search'); + + component.submitSearch(); + + expect(routerMock.navigate).toHaveBeenCalledWith(['/search'], { + queryParams: { search: 'test search', tab: ResourceType.Preprint }, + }); + }); + + it('should not navigate when search value is empty', () => { + setup(); + component.searchControl.setValue(''); + + component.submitSearch(); + + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + + it('should not navigate when search value is whitespace only', () => { + setup(); + component.searchControl.setValue(' '); + + component.submitSearch(); + + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/preprints/pages/landing/preprints-landing.component.ts b/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.ts similarity index 72% rename from src/app/features/preprints/pages/landing/preprints-landing.component.ts rename to src/app/features/preprints/pages/preprints-landing/preprints-landing.component.ts index b6bcfc29d..0f66c9315 100644 --- a/src/app/features/preprints/pages/landing/preprints-landing.component.ts +++ b/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.ts @@ -6,7 +6,7 @@ import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; import { TitleCasePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, effect, HostBinding, inject, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, HostBinding, inject, OnDestroy } from '@angular/core'; import { FormControl } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; @@ -25,15 +25,15 @@ import { } from '../../store/preprint-providers'; @Component({ - selector: 'osf-overview', + selector: 'osf-preprints-landing', imports: [ Button, - SearchInputComponent, + Skeleton, RouterLink, + SearchInputComponent, AdvisoryBoardComponent, PreprintServicesComponent, BrowseBySubjectsComponent, - Skeleton, TranslatePipe, TitleCasePipe, ], @@ -41,33 +41,37 @@ import { styleUrl: './preprints-landing.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PreprintsLandingComponent implements OnInit, OnDestroy { +export class PreprintsLandingComponent implements OnDestroy { @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; - searchControl = new FormControl(''); - private readonly environment = inject(ENVIRONMENT); private readonly brandService = inject(BrandService); + private readonly router = inject(Router); readonly supportEmail = this.environment.supportEmail; - private readonly OSF_PROVIDER_ID = this.environment.defaultProvider; + private readonly defaultProviderId = this.environment.defaultProvider; + + searchControl = new FormControl('', { nonNullable: true }); - private readonly router = inject(Router); private readonly actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, getPreprintProvidersToAdvertise: GetPreprintProvidersToAdvertise, getHighlightedSubjectsByProviderId: GetHighlightedSubjectsByProviderId, }); - osfPreprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.OSF_PROVIDER_ID)); - isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); + provider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.defaultProviderId)); + isProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); preprintProvidersToAdvertise = select(PreprintProvidersSelectors.getPreprintProvidersToAdvertise); - highlightedSubjectsByProviderId = select(PreprintProvidersSelectors.getHighlightedSubjectsForProvider); + highlightedSubjects = select(PreprintProvidersSelectors.getHighlightedSubjectsForProvider); areSubjectsLoading = select(PreprintProvidersSelectors.areSubjectsLoading); constructor() { + this.actions.getPreprintProviderById(this.defaultProviderId); + this.actions.getPreprintProvidersToAdvertise(); + this.actions.getHighlightedSubjectsByProviderId(this.defaultProviderId); + effect(() => { - const provider = this.osfPreprintProvider(); + const provider = this.provider(); if (provider) { this.brandService.applyBranding(provider.brand); @@ -75,21 +79,17 @@ export class PreprintsLandingComponent implements OnInit, OnDestroy { }); } - ngOnInit(): void { - this.actions.getPreprintProviderById(this.OSF_PROVIDER_ID); - this.actions.getPreprintProvidersToAdvertise(); - this.actions.getHighlightedSubjectsByProviderId(this.OSF_PROVIDER_ID); - } - ngOnDestroy() { this.brandService.resetBranding(); } - redirectToSearchPageWithValue() { - const searchValue = normalizeQuotes(this.searchControl.value); + submitSearch(): void { + const searchValue = normalizeQuotes(this.searchControl.value)?.trim(); - this.router.navigate(['/search'], { - queryParams: { search: searchValue, tab: ResourceType.Preprint }, - }); + if (!searchValue) { + return; + } + + this.router.navigate(['/search'], { queryParams: { search: searchValue, tab: ResourceType.Preprint } }); } } diff --git a/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.html b/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.html index d181f2b74..3cbea61e9 100644 --- a/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.html +++ b/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.html @@ -24,14 +24,14 @@

{{ 'preprints.selectService.sectionTitle' | translate }}

} } @else { - @for (provider of preprintProvidersAllowingSubmissions(); track $index) { + @for (provider of preprintProvidersAllowingSubmissions(); track provider.id) {
{{ provider.name }}

-
+
} } diff --git a/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.spec.ts b/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.spec.ts index fa17b37e7..6d77b585c 100644 --- a/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.spec.ts +++ b/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.spec.ts @@ -1,137 +1,98 @@ -import { TranslatePipe } from '@ngx-translate/core'; -import { MockComponents, MockPipe, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { PreprintProviderShortInfo } from '../../models'; -import { PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { PreprintStepperSelectors } from '../../store/preprint-stepper'; +import { GetPreprintProvidersAllowingSubmissions, PreprintProvidersSelectors } from '../../store/preprint-providers'; +import { PreprintStepperSelectors, SetSelectedPreprintProviderId } from '../../store/preprint-stepper'; import { SelectPreprintServiceComponent } from './select-preprint-service.component'; import { PREPRINT_PROVIDER_SHORT_INFO_MOCK } from '@testing/mocks/preprint-provider-short-info.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; describe('SelectPreprintServiceComponent', () => { let component: SelectPreprintServiceComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; - let routeMock: ReturnType; - - const mockProviders: PreprintProviderShortInfo[] = [PREPRINT_PROVIDER_SHORT_INFO_MOCK]; - const mockSelectedProviderId = 'osf'; - - beforeEach(async () => { - routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); - routeMock = ActivatedRouteMockBuilder.create().withParams({}).withQueryParams({}).build(); - - await TestBed.configureTestingModule({ - imports: [ - SelectPreprintServiceComponent, - OSFTestingModule, - ...MockComponents(SubHeaderComponent), - MockPipe(TranslatePipe), - ], + let store: Store; + + const mockProvider: PreprintProviderShortInfo = PREPRINT_PROVIDER_SHORT_INFO_MOCK; + const mockProviders: PreprintProviderShortInfo[] = [mockProvider]; + + const defaultSignals: SignalOverride[] = [ + { selector: PreprintProvidersSelectors.getPreprintProvidersAllowingSubmissions, value: mockProviders }, + { selector: PreprintProvidersSelectors.arePreprintProvidersAllowingSubmissionsLoading, value: false }, + { selector: PreprintStepperSelectors.getSelectedProviderId, value: null }, + ]; + + function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [SelectPreprintServiceComponent, ...MockComponents(SubHeaderComponent)], providers: [ - MockProvider(Router, routerMock), - MockProvider(ActivatedRoute, routeMock), - provideMockStore({ - signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProvidersAllowingSubmissions, - value: mockProviders, - }, - { - selector: PreprintProvidersSelectors.arePreprintProvidersAllowingSubmissionsLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.getSelectedProviderId, - value: mockSelectedProviderId, - }, - ], - }), + provideOSFCore(), + MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().build()), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(SelectPreprintServiceComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } - it('should create', () => { - expect(component).toBeTruthy(); + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); }); - it('should return preprint providers from store', () => { - const providers = component.preprintProvidersAllowingSubmissions(); - expect(providers).toBe(mockProviders); - }); + it('should initialize with correct default values', () => { + setup(); - it('should return loading state from store', () => { - const loading = component.areProvidersLoading(); - expect(loading).toBe(false); + expect(component.classes).toBe('flex-1 flex flex-column w-full'); + expect(component.skeletonArray.length).toBe(8); }); - it('should return selected provider ID from store', () => { - const selectedId = component.selectedProviderId(); - expect(selectedId).toBe(mockSelectedProviderId); - }); + it('should dispatch fetch action on creation', () => { + setup(); - it('should handle provider data correctly', () => { - const providers = component.preprintProvidersAllowingSubmissions(); - expect(providers).toBe(mockProviders); - expect(Array.isArray(providers)).toBe(true); - expect(providers.length).toBe(1); - expect(providers[0].id).toBe(mockProviders[0].id); + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProvidersAllowingSubmissions()); }); - it('should handle loading states correctly', () => { - const loading = component.areProvidersLoading(); - expect(typeof loading).toBe('boolean'); - expect(loading).toBe(false); - }); + it('should dispatch select action when toggling an unselected provider', () => { + setup(); - it('should handle selected provider ID correctly', () => { - const selectedId = component.selectedProviderId(); - expect(selectedId).toBe(mockSelectedProviderId); - expect(typeof selectedId).toBe('string'); - }); + component.toggleProviderSelection(mockProvider); - it('should initialize skeleton array correctly', () => { - expect(component.skeletonArray).toBeDefined(); - expect(Array.isArray(component.skeletonArray)).toBe(true); - expect(component.skeletonArray.length).toBe(8); - expect(component.skeletonArray).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedPreprintProviderId(mockProvider.id)); }); - it('should handle provider selection when provider is not selected', () => { - const provider = mockProviders[0]; - component.selectDeselectProvider(provider); + it('should dispatch deselect action when toggling the already selected provider', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getSelectedProviderId, value: mockProvider.id }], + }); - expect(component.selectedProviderId()).toBe(mockSelectedProviderId); - }); - - it('should handle provider deselection when provider is already selected', () => { - const provider = mockProviders[0]; + component.toggleProviderSelection(mockProvider); - expect(() => component.selectDeselectProvider(provider)).not.toThrow(); + expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedPreprintProviderId(null)); }); - it('should handle empty providers array', () => { - const providers = component.preprintProvidersAllowingSubmissions(); - expect(providers).toBeDefined(); - expect(Array.isArray(providers)).toBe(true); - }); + it('should dispatch select action when toggling a different provider', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getSelectedProviderId, value: 'other-provider' }], + }); + + component.toggleProviderSelection(mockProvider); - it('should handle null selected provider ID', () => { - const selectedId = component.selectedProviderId(); - expect(selectedId).toBeDefined(); + expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedPreprintProviderId(mockProvider.id)); }); }); diff --git a/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.ts b/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.ts index c726d4ac5..e47218751 100644 --- a/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.ts +++ b/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.ts @@ -8,10 +8,11 @@ import { Skeleton } from 'primeng/skeleton'; import { Tooltip } from 'primeng/tooltip'; import { NgClass } from '@angular/common'; -import { ChangeDetectionStrategy, Component, HostBinding, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core'; import { RouterLink } from '@angular/router'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { SafeHtmlPipe } from '@osf/shared/pipes/safe-html.pipe'; import { PreprintProviderShortInfo } from '../../models'; import { GetPreprintProvidersAllowingSubmissions, PreprintProvidersSelectors } from '../../store/preprint-providers'; @@ -19,12 +20,12 @@ import { PreprintStepperSelectors, SetSelectedPreprintProviderId } from '../../s @Component({ selector: 'osf-select-preprint-service', - imports: [SubHeaderComponent, Card, Button, NgClass, Tooltip, Skeleton, TranslatePipe, RouterLink], + imports: [SubHeaderComponent, Card, Button, NgClass, Tooltip, Skeleton, RouterLink, TranslatePipe, SafeHtmlPipe], templateUrl: './select-preprint-service.component.html', styleUrl: './select-preprint-service.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SelectPreprintServiceComponent implements OnInit { +export class SelectPreprintServiceComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; private actions = createDispatchMap({ @@ -35,13 +36,13 @@ export class SelectPreprintServiceComponent implements OnInit { preprintProvidersAllowingSubmissions = select(PreprintProvidersSelectors.getPreprintProvidersAllowingSubmissions); areProvidersLoading = select(PreprintProvidersSelectors.arePreprintProvidersAllowingSubmissionsLoading); selectedProviderId = select(PreprintStepperSelectors.getSelectedProviderId); - skeletonArray = Array.from({ length: 8 }, (_, i) => i + 1); + skeletonArray = new Array(8); - ngOnInit(): void { + constructor() { this.actions.getPreprintProvidersAllowingSubmissions(); } - selectDeselectProvider(provider: PreprintProviderShortInfo) { + toggleProviderSelection(provider: PreprintProviderShortInfo): void { if (provider.id === this.selectedProviderId()) { this.actions.setSelectedPreprintProviderId(null); return; diff --git a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.html b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.html index fd53f63ae..9d37e8e03 100644 --- a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.html +++ b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.html @@ -6,9 +6,9 @@ } @else {

{{ 'preprints.addPreprint' | translate: { preprintWord: preprintProvider()!.preprintWord } }} @@ -22,7 +22,7 @@

} @else { @@ -32,30 +32,30 @@

@switch (currentStep().value) { - @case (SubmitStepsEnum.TitleAndAbstract) { + @case (PreprintSteps.TitleAndAbstract) { } - @case (SubmitStepsEnum.File) { + @case (PreprintSteps.File) { } - @case (SubmitStepsEnum.Metadata) { + @case (PreprintSteps.Metadata) { } - @case (SubmitStepsEnum.AuthorAssertions) { + @case (PreprintSteps.AuthorAssertions) { } - @case (SubmitStepsEnum.Supplements) { + @case (PreprintSteps.Supplements) { } - @case (SubmitStepsEnum.Review) { + @case (PreprintSteps.Review) { } } diff --git a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts index 50920be2a..69359a93f 100644 --- a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts +++ b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts @@ -1,13 +1,14 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; -import { StepOption } from '@osf/shared/models/step-option.model'; import { BrandService } from '@osf/shared/services/brand.service'; import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; import { HeaderStyleService } from '@osf/shared/services/header-style.service'; @@ -23,37 +24,48 @@ import { import { submitPreprintSteps } from '../../constants'; import { PreprintSteps } from '../../enums'; import { PreprintProviderDetails } from '../../models'; -import { PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { PreprintStepperSelectors } from '../../store/preprint-stepper'; +import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; +import { DeletePreprint, PreprintStepperSelectors, ResetPreprintStepperState } from '../../store/preprint-stepper'; import { SubmitPreprintStepperComponent } from './submit-preprint-stepper.component'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { BrandServiceMock, BrandServiceMockType } from '@testing/providers/brand-service.mock'; +import { BrowserTabServiceMock, BrowserTabServiceMockType } from '@testing/providers/browser-tab-service.mock'; +import { HeaderStyleServiceMock, HeaderStyleServiceMockType } from '@testing/providers/header-style-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; describe('SubmitPreprintStepperComponent', () => { let component: SubmitPreprintStepperComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; - let routeMock: ReturnType; + let store: Store; + let brandServiceMock: BrandServiceMockType; + let headerStyleMock: HeaderStyleServiceMockType; + let browserTabMock: BrowserTabServiceMockType; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; const mockProviderId = 'osf'; - beforeEach(async () => { - routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); - routeMock = ActivatedRouteMockBuilder.create() - .withParams({ providerId: mockProviderId }) - .withQueryParams({}) - .build(); + const defaultSignals: SignalOverride[] = [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: mockProvider }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + { selector: PreprintStepperSelectors.hasBeenSubmitted, value: false }, + ]; + + function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + + const routeMock = ActivatedRouteMockBuilder.create().withParams({ providerId: mockProviderId }).build(); + + brandServiceMock = BrandServiceMock.simple(); + headerStyleMock = HeaderStyleServiceMock.simple(); + browserTabMock = BrowserTabServiceMock.simple(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ SubmitPreprintStepperComponent, - OSFTestingModule, ...MockComponents( StepperComponent, TitleAndAbstractStepComponent, @@ -61,128 +73,198 @@ describe('SubmitPreprintStepperComponent', () => { PreprintsMetadataStepComponent, AuthorAssertionsStepComponent, SupplementsStepComponent, - AuthorAssertionsStepComponent, ReviewStepComponent ), ], providers: [ - MockProvider(BrandService), - MockProvider(BrowserTabService), - MockProvider(HeaderStyleService), - MockProvider(Router, routerMock), + provideOSFCore(), MockProvider(ActivatedRoute, routeMock), + MockProvider(BrandService, brandServiceMock), + MockProvider(HeaderStyleService, headerStyleMock), + MockProvider(BrowserTabService, browserTabMock), MockProvider(IS_WEB, of(true)), - provideMockStore({ - signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.hasBeenSubmitted, - value: false, - }, - ], - }), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(SubmitPreprintStepperComponent); component = fixture.componentInstance; fixture.detectChanges(); + } + + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); }); it('should initialize with correct default values', () => { - expect(component.SubmitStepsEnum).toBe(PreprintSteps); + setup(); + + expect(component.PreprintSteps).toBe(PreprintSteps); expect(component.classes).toBe('flex-1 flex flex-column w-full'); expect(component.currentStep()).toEqual(submitPreprintSteps[0]); }); - it('should return submission state from store', () => { - const submitted = component.hasBeenSubmitted(); - expect(submitted).toBe(false); + it('should dispatch initial action on creation', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProviderById(mockProviderId)); }); - it('should return web environment state', () => { - const isWeb = component.isWeb(); - expect(typeof isWeb).toBe('boolean'); + it('should apply branding when provider is available', () => { + setup(); + + expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(mockProvider.brand); + expect(headerStyleMock.applyHeaderStyles).toHaveBeenCalledWith( + mockProvider.brand.primaryColor, + mockProvider.brand.secondaryColor, + mockProvider.brand.heroBackgroundImageUrl + ); + expect(browserTabMock.updateTabStyles).toHaveBeenCalledWith(mockProvider.faviconUrl, mockProvider.name); }); - it('should initialize with first step as current step', () => { - expect(component.currentStep()).toEqual(submitPreprintSteps[0]); + it('should reset services and delete preprint on destroy', () => { + setup(); + + component.ngOnDestroy(); + + expect(headerStyleMock.resetToDefaults).toHaveBeenCalled(); + expect(brandServiceMock.resetBranding).toHaveBeenCalled(); + expect(browserTabMock.resetToDefaults).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new DeletePreprint()); + expect(store.dispatch).toHaveBeenCalledWith(new ResetPreprintStepperState()); }); - it('should compute submitPreprintSteps correctly', () => { - const steps = component.submitPreprintSteps(); - expect(steps).toBeDefined(); - expect(Array.isArray(steps)).toBe(true); + it('should filter out AuthorAssertions step when assertions are disabled', () => { + setup(); + + const stepValues = component.steps().map((s) => s.value); + + expect(stepValues).not.toContain(PreprintSteps.AuthorAssertions); + expect(stepValues).toContain(PreprintSteps.TitleAndAbstract); + expect(stepValues).toContain(PreprintSteps.File); + expect(stepValues).toContain(PreprintSteps.Metadata); + expect(stepValues).toContain(PreprintSteps.Supplements); + expect(stepValues).toContain(PreprintSteps.Review); + }); + + it('should include AuthorAssertions step when assertions are enabled', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), + value: { ...mockProvider, assertionsEnabled: true }, + }, + ], + }); + + const stepValues = component.steps().map((s) => s.value); + expect(stepValues).toContain(PreprintSteps.AuthorAssertions); }); - it('should handle step change when moving to previous step', () => { - const previousStep = submitPreprintSteps[0]; + it('should re-index steps sequentially', () => { + setup(); + + const steps = component.steps(); + steps.forEach((step, i) => expect(step.index).toBe(i)); + }); - component.stepChange(previousStep); + it('should return empty steps when provider is unavailable', () => { + setup({ + selectorOverrides: [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: null }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: true }, + ], + }); - expect(component.currentStep()).toEqual(previousStep); + expect(component.steps()).toEqual([]); }); - it('should not change step when moving to next step', () => { - const currentStep = component.currentStep(); - const nextStep = submitPreprintSteps[1]; + it('should prevent beforeunload when not submitted', () => { + setup(); + const event = { preventDefault: jest.fn() } as unknown as BeforeUnloadEvent; - component.stepChange(nextStep); + component.onBeforeUnload(event); - expect(component.currentStep()).toEqual(currentStep); + expect(event.preventDefault).toHaveBeenCalled(); }); - it('should move to next step', () => { - const currentIndex = component.currentStep()?.index ?? 0; - const nextStep = component.submitPreprintSteps()[currentIndex + 1]; + it('should not prevent beforeunload when submitted', () => { + setup({ selectorOverrides: [{ selector: PreprintStepperSelectors.hasBeenSubmitted, value: true }] }); + const event = { preventDefault: jest.fn() } as unknown as BeforeUnloadEvent; + + component.onBeforeUnload(event); - if (nextStep) { - component.moveToNextStep(); - expect(component.currentStep()).toEqual(nextStep); - } + expect(event.preventDefault).not.toHaveBeenCalled(); }); - it('should move to previous step', () => { + it('should prevent deactivation when not submitted', () => { + setup(); + + expect(component.canDeactivate()).toBe(false); + }); + + it('should allow deactivation when submitted', () => { + setup({ selectorOverrides: [{ selector: PreprintStepperSelectors.hasBeenSubmitted, value: true }] }); + + expect(component.canDeactivate()).toBe(true); + }); + + it('should ignore stepping forward via stepper', () => { + setup(); + + component.stepChange(component.steps()[1]); + + expect(component.currentStep()).toEqual(submitPreprintSteps[0]); + }); + + it('should allow stepping back via stepper', () => { + setup(); component.moveToNextStep(); - const nextStep = component.currentStep(); - component.moveToPreviousStep(); - const previousStep = component.currentStep(); + component.stepChange(component.steps()[0]); - expect(previousStep?.index).toBeLessThan(nextStep?.index ?? 0); + expect(component.currentStep()).toEqual(component.steps()[0]); }); - it('should handle beforeunload event', () => { - const event = { - preventDefault: jest.fn(), - } as unknown as BeforeUnloadEvent; + it('should move to next step', () => { + setup(); + const expectedNext = component.steps()[1]; - const result = component.onBeforeUnload(event); + component.moveToNextStep(); - expect(event.preventDefault).toHaveBeenCalled(); - expect(result).toBe(false); + expect(component.currentStep()).toEqual(expectedNext); + }); + + it('should not move past the last step', () => { + setup(); + const steps = component.steps(); + const lastStep = steps[steps.length - 1]; + component.currentStep.set(lastStep); + + component.moveToNextStep(); + + expect(component.currentStep()).toEqual(lastStep); }); - it('should handle step navigation correctly', () => { + it('should move to previous step', () => { + setup(); component.moveToNextStep(); - const nextStep = component.currentStep(); - expect(nextStep).toBeDefined(); + const firstStep = component.steps()[0]; component.moveToPreviousStep(); - const previousStep = component.currentStep(); - expect(previousStep).toBeDefined(); + + expect(component.currentStep()).toEqual(firstStep); }); - it('should handle edge case when moving to next step with undefined current step', () => { - component.currentStep.set({} as StepOption); + it('should not move before the first step', () => { + setup(); + const firstStep = component.steps()[0]; + component.currentStep.set(firstStep); + + component.moveToPreviousStep(); - expect(() => component.moveToNextStep()).not.toThrow(); + expect(component.currentStep()).toEqual(firstStep); }); }); diff --git a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts index 6bfcfb1ea..21e5c3ac2 100644 --- a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts +++ b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts @@ -4,8 +4,9 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Skeleton } from 'primeng/skeleton'; -import { map, Observable, of } from 'rxjs'; +import { map } from 'rxjs'; +import { DOCUMENT } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -15,7 +16,6 @@ import { HostListener, inject, OnDestroy, - OnInit, signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -57,7 +57,6 @@ import { PreprintsMetadataStepComponent, AuthorAssertionsStepComponent, SupplementsStepComponent, - AuthorAssertionsStepComponent, ReviewStepComponent, TranslatePipe, ], @@ -65,15 +64,16 @@ import { styleUrl: './submit-preprint-stepper.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDeactivateComponent { +export class SubmitPreprintStepperComponent implements OnDestroy, CanDeactivateComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; private readonly route = inject(ActivatedRoute); + private readonly document = inject(DOCUMENT); private readonly brandService = inject(BrandService); private readonly headerStyleHelper = inject(HeaderStyleService); private readonly browserTabHelper = inject(BrowserTabService); - private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); + private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId']))); private actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, @@ -82,7 +82,7 @@ export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDea deletePreprint: DeletePreprint, }); - readonly SubmitStepsEnum = PreprintSteps; + readonly PreprintSteps = PreprintSteps; preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); @@ -90,7 +90,7 @@ export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDea currentStep = signal(submitPreprintSteps[0]); isWeb = toSignal(inject(IS_WEB)); - readonly submitPreprintSteps = computed(() => { + readonly steps = computed(() => { const provider = this.preprintProvider(); if (!provider) { @@ -98,21 +98,13 @@ export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDea } return submitPreprintSteps - .map((step) => { - if (!provider.assertionsEnabled && step.value === PreprintSteps.AuthorAssertions) { - return null; - } - - return step; - }) - .filter((step) => step !== null) - .map((step, index) => ({ - ...step, - index, - })); + .filter((step) => step.value !== PreprintSteps.AuthorAssertions || provider.assertionsEnabled) + .map((step, index) => ({ ...step, index })); }); constructor() { + this.actions.getPreprintProviderById(this.providerId()); + effect(() => { const provider = this.preprintProvider(); @@ -129,12 +121,15 @@ export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDea }); } - canDeactivate(): Observable | boolean { - return this.hasBeenSubmitted(); + @HostListener('window:beforeunload', ['$event']) + onBeforeUnload($event: BeforeUnloadEvent): void { + if (!this.hasBeenSubmitted()) { + $event.preventDefault(); + } } - ngOnInit() { - this.actions.getPreprintProviderById(this.providerId()); + canDeactivate(): boolean { + return this.hasBeenSubmitted(); } ngOnDestroy() { @@ -146,8 +141,7 @@ export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDea } stepChange(step: StepOption): void { - const currentStepIndex = this.currentStep()?.index ?? 0; - if (step.index >= currentStepIndex) { + if (step.index >= this.currentStep().index) { return; } @@ -155,27 +149,29 @@ export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDea this.scrollToTop(); } - moveToNextStep() { - this.currentStep.set(this.submitPreprintSteps()[this.currentStep()?.index + 1]); - this.scrollToTop(); + moveToNextStep(): void { + const nextStep = this.steps()[this.currentStep().index + 1]; + + if (nextStep) { + this.currentStep.set(nextStep); + this.scrollToTop(); + } } - moveToPreviousStep() { - this.currentStep.set(this.submitPreprintSteps()[this.currentStep()?.index - 1]); - this.scrollToTop(); + moveToPreviousStep(): void { + const prevStep = this.steps()[this.currentStep().index - 1]; + + if (prevStep) { + this.currentStep.set(prevStep); + this.scrollToTop(); + } } - scrollToTop() { - const contentWrapper = document.querySelector('.content-wrapper') as HTMLElement; + private scrollToTop(): void { + const contentWrapper = this.document.querySelector('.content-wrapper') as HTMLElement; if (contentWrapper) { contentWrapper.scrollTo({ top: 0, behavior: 'instant' }); } } - - @HostListener('window:beforeunload', ['$event']) - public onBeforeUnload($event: BeforeUnloadEvent): boolean { - $event.preventDefault(); - return false; - } } diff --git a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html index 44991c051..adefb2ec5 100644 --- a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html +++ b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html @@ -6,9 +6,9 @@ } @else {

{{ 'preprints.updatePreprint' | translate: { preprintWord: preprintProvider()!.preprintWord } }} @@ -35,7 +35,7 @@

@case (PreprintSteps.TitleAndAbstract) { } - @case (SubmitStepsEnum.File) { + @case (PreprintSteps.File) { { let component: UpdatePreprintStepperComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; - let routeMock: ReturnType; + let store: Store; + let brandServiceMock: BrandServiceMockType; + let headerStyleMock: HeaderStyleServiceMockType; + let browserTabMock: BrowserTabServiceMockType; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; const mockPreprint = PREPRINT_MOCK; const mockProviderId = 'osf'; const mockPreprintId = 'test_preprint_123'; - beforeEach(async () => { - routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); - routeMock = ActivatedRouteMockBuilder.create() + const defaultSignals: SignalOverride[] = [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: mockProvider }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + { selector: PreprintStepperSelectors.getPreprint, value: mockPreprint }, + { selector: PreprintStepperSelectors.hasBeenSubmitted, value: false }, + { selector: PreprintStepperSelectors.hasAdminAccess, value: false }, + ]; + + function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + + const routeMock = ActivatedRouteMockBuilder.create() .withParams({ providerId: mockProviderId, preprintId: mockPreprintId }) - .withQueryParams({}) .build(); - await TestBed.configureTestingModule({ + brandServiceMock = BrandServiceMock.simple(); + headerStyleMock = HeaderStyleServiceMock.simple(); + browserTabMock = BrowserTabServiceMock.simple(); + + TestBed.configureTestingModule({ imports: [ UpdatePreprintStepperComponent, - OSFTestingModule, ...MockComponents( AuthorAssertionsStepComponent, StepperComponent, @@ -68,159 +84,234 @@ describe('UpdatePreprintStepperComponent', () => { ), ], providers: [ - MockProvider(BrandService), - MockProvider(BrowserTabService), - MockProvider(HeaderStyleService), - MockProvider(Router, routerMock), + provideOSFCore(), MockProvider(ActivatedRoute, routeMock), + MockProvider(BrandService, brandServiceMock), + MockProvider(HeaderStyleService, headerStyleMock), + MockProvider(BrowserTabService, browserTabMock), MockProvider(IS_WEB, of(true)), - provideMockStore({ - signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintStepperSelectors.hasBeenSubmitted, - value: false, - }, - ], - }), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(UpdatePreprintStepperComponent); component = fixture.componentInstance; fixture.detectChanges(); + } + + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); }); it('should initialize with correct default values', () => { + setup(); + expect(component.PreprintSteps).toBe(PreprintSteps); expect(component.classes).toBe('flex-1 flex flex-column w-full'); expect(component.currentStep()).toEqual(submitPreprintSteps[0]); }); - it('should return preprint provider from store', () => { - const provider = component.preprintProvider(); - expect(provider).toBe(mockProvider); + it('should dispatch initial actions on creation', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProviderById(mockProviderId)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintById(mockPreprintId)); }); - it('should return preprint from store', () => { - const preprint = component.preprint(); - expect(preprint).toBe(mockPreprint); + it('should apply branding when provider is available', () => { + setup(); + + expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(mockProvider.brand); + expect(headerStyleMock.applyHeaderStyles).toHaveBeenCalledWith( + mockProvider.brand.primaryColor, + mockProvider.brand.secondaryColor, + mockProvider.brand.heroBackgroundImageUrl + ); + expect(browserTabMock.updateTabStyles).toHaveBeenCalledWith(mockProvider.faviconUrl, mockProvider.name); }); - it('should return web environment state', () => { - const isWeb = component.isWeb(); - expect(typeof isWeb).toBe('boolean'); + it('should reset services on destroy', () => { + setup(); + + component.ngOnDestroy(); + + expect(headerStyleMock.resetToDefaults).toHaveBeenCalled(); + expect(brandServiceMock.resetBranding).toHaveBeenCalled(); + expect(browserTabMock.resetToDefaults).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new ResetPreprintStepperState()); }); - it('should initialize with first step as current step', () => { - expect(component.currentStep()).toEqual(submitPreprintSteps[0]); + it('should filter out File and AuthorAssertions steps by default', () => { + setup(); + + const steps = component.updateSteps(); + const stepValues = steps.map((s) => s.value); + + expect(stepValues).not.toContain(PreprintSteps.File); + expect(stepValues).not.toContain(PreprintSteps.AuthorAssertions); + expect(stepValues).toContain(PreprintSteps.TitleAndAbstract); + expect(stepValues).toContain(PreprintSteps.Metadata); + expect(stepValues).toContain(PreprintSteps.Supplements); + expect(stepValues).toContain(PreprintSteps.Review); }); - it('should compute updateSteps correctly', () => { + it('should re-index steps sequentially', () => { + setup(); + const steps = component.updateSteps(); - expect(steps).toBeDefined(); - expect(Array.isArray(steps)).toBe(true); + steps.forEach((step, i) => expect(step.index).toBe(i)); }); - it('should compute currentUserIsAdmin correctly', () => { - const isAdmin = component.currentUserIsAdmin(); - expect(typeof isAdmin).toBe('boolean'); + it('should return empty steps when provider is unavailable', () => { + setup({ + selectorOverrides: [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: null }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: true }, + ], + }); + + expect(component.updateSteps()).toEqual([]); }); - it('should compute editAndResubmitMode correctly', () => { - const editMode = component.editAndResubmitMode(); - expect(typeof editMode).toBe('boolean'); + it('should return empty steps when preprint is unavailable', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + }); + + expect(component.updateSteps()).toEqual([]); }); - it('should handle step change when moving to previous step', () => { - const previousStep = submitPreprintSteps[0]; + it('should include File step in edit-and-resubmit mode', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintStepperSelectors.getPreprint, + value: { ...mockPreprint, reviewsState: ReviewsState.Rejected }, + }, + ], + }); - component.stepChange(previousStep); + const stepValues = component.updateSteps().map((s) => s.value); + expect(stepValues).toContain(PreprintSteps.File); + }); + + it('should include AuthorAssertions step when enabled and user has admin access', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), + value: { ...mockProvider, assertionsEnabled: true }, + }, + { selector: PreprintStepperSelectors.hasAdminAccess, value: true }, + ], + }); - expect(component.currentStep()).toEqual(previousStep); + const stepValues = component.updateSteps().map((s) => s.value); + expect(stepValues).toContain(PreprintSteps.AuthorAssertions); }); - it('should not change step when moving to next step', () => { - const currentStep = component.currentStep(); - const nextStep = submitPreprintSteps[1]; + it('should prevent beforeunload when not submitted', () => { + setup(); + const event = { preventDefault: jest.fn() } as unknown as BeforeUnloadEvent; - component.stepChange(nextStep); + component.onBeforeUnload(event); - expect(component.currentStep()).toEqual(currentStep); + expect(event.preventDefault).toHaveBeenCalled(); }); - it('should move to next step', () => { - const currentIndex = component.currentStep()?.index ?? 0; - const nextStep = component.updateSteps()[currentIndex + 1]; + it('should not prevent beforeunload when submitted', () => { + setup({ selectorOverrides: [{ selector: PreprintStepperSelectors.hasBeenSubmitted, value: true }] }); + const event = { preventDefault: jest.fn() } as unknown as BeforeUnloadEvent; + + component.onBeforeUnload(event); - if (nextStep) { - component.moveToNextStep(); - expect(component.currentStep()).toEqual(nextStep); - } + expect(event.preventDefault).not.toHaveBeenCalled(); }); - it('should move to previous step', () => { - component.moveToNextStep(); - const nextStep = component.currentStep(); + it('should not prevent beforeunload when preprint is accepted', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintStepperSelectors.getPreprint, + value: { ...mockPreprint, reviewsState: ReviewsState.Accepted }, + }, + ], + }); + const event = { preventDefault: jest.fn() } as unknown as BeforeUnloadEvent; - component.moveToPreviousStep(); - const previousStep = component.currentStep(); + component.onBeforeUnload(event); - expect(previousStep?.index).toBeLessThan(nextStep?.index ?? 0); + expect(event.preventDefault).not.toHaveBeenCalled(); }); - it('should handle beforeunload event', () => { - const event = { - preventDefault: jest.fn(), - } as unknown as BeforeUnloadEvent; + it('should prevent deactivation when not submitted', () => { + setup(); + + expect(component.canDeactivate()).toBe(false); + }); - const result = component.onBeforeUnload(event); + it('should allow deactivation when submitted', () => { + setup({ selectorOverrides: [{ selector: PreprintStepperSelectors.hasBeenSubmitted, value: true }] }); - expect(event.preventDefault).toHaveBeenCalled(); - expect(result).toBe(false); + expect(component.canDeactivate()).toBe(true); }); - it('should handle step navigation correctly', () => { - component.moveToNextStep(); - const nextStep = component.currentStep(); - expect(nextStep).toBeDefined(); + it('should ignore stepping forward via stepper', () => { + setup(); - component.moveToPreviousStep(); - const previousStep = component.currentStep(); - expect(previousStep).toBeDefined(); + component.stepChange(component.updateSteps()[1]); + + expect(component.currentStep()).toEqual(submitPreprintSteps[0]); }); - it('should handle edge case when moving to next step with undefined current step', () => { - component.currentStep.set({} as StepOption); + it('should allow stepping back via stepper', () => { + setup(); + component.moveToNextStep(); + + component.stepChange(component.updateSteps()[0]); - expect(() => component.moveToNextStep()).not.toThrow(); + expect(component.currentStep()).toEqual(component.updateSteps()[0]); }); - it('should handle edge case when moving to previous step with undefined current step', () => { - component.currentStep.set({} as StepOption); + it('should move to next step', () => { + setup(); + const expectedNext = component.updateSteps()[1]; + + component.moveToNextStep(); - expect(() => component.moveToPreviousStep()).not.toThrow(); + expect(component.currentStep()).toEqual(expectedNext); }); - it('should handle empty updateSteps array', () => { + it('should not move past the last step', () => { + setup(); const steps = component.updateSteps(); - expect(steps).toBeDefined(); - expect(Array.isArray(steps)).toBe(true); + const lastStep = steps[steps.length - 1]; + component.currentStep.set(lastStep); + + component.moveToNextStep(); + + expect(component.currentStep()).toEqual(lastStep); }); - it('should handle null preprint provider', () => { - const provider = component.preprintProvider(); - expect(provider).toBeDefined(); + it('should move to previous step', () => { + setup(); + component.moveToNextStep(); + const firstStep = component.updateSteps()[0]; + + component.moveToPreviousStep(); + + expect(component.currentStep()).toEqual(firstStep); + }); + + it('should not move before the first step', () => { + setup(); + const firstStep = component.updateSteps()[0]; + component.currentStep.set(firstStep); + + component.moveToPreviousStep(); + + expect(component.currentStep()).toEqual(firstStep); }); }); diff --git a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts index f7023e75a..c3756fc22 100644 --- a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts +++ b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts @@ -4,7 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Skeleton } from 'primeng/skeleton'; -import { map, Observable, of } from 'rxjs'; +import { map } from 'rxjs'; import { ChangeDetectionStrategy, @@ -15,14 +15,12 @@ import { HostListener, inject, OnDestroy, - OnInit, signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; import { BrandService } from '@osf/shared/services/brand.service'; import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; @@ -51,8 +49,8 @@ import { @Component({ selector: 'osf-update-preprint-stepper', imports: [ - AuthorAssertionsStepComponent, Skeleton, + AuthorAssertionsStepComponent, StepperComponent, TitleAndAbstractStepComponent, PreprintsMetadataStepComponent, @@ -65,7 +63,7 @@ import { styleUrl: './update-preprint-stepper.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDeactivateComponent { +export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; private readonly route = inject(ActivatedRoute); @@ -73,8 +71,8 @@ export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDea private readonly headerStyleHelper = inject(HeaderStyleService); private readonly browserTabHelper = inject(BrowserTabService); - private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); - private preprintId = toSignal(this.route.params.pipe(map((params) => params['preprintId'])) ?? of(undefined)); + private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId']))); + private preprintId = toSignal(this.route.params.pipe(map((params) => params['preprintId']))); private actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, @@ -83,12 +81,13 @@ export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDea fetchPreprint: FetchPreprintById, }); + readonly PreprintSteps = PreprintSteps; + preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); preprint = select(PreprintStepperSelectors.getPreprint); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); - - currentUserIsAdmin = computed(() => this.preprint()?.currentUserPermissions.includes(UserPermissions.Admin) || false); + hasAdminAccess = select(PreprintStepperSelectors.hasAdminAccess); editAndResubmitMode = computed(() => { const providerIsPremod = this.preprintProvider()?.reviewsWorkflow === ProviderReviewsWorkflow.PreModeration; @@ -106,39 +105,25 @@ export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDea } return submitPreprintSteps - .map((step) => { - if (step.value !== PreprintSteps.File) { - return step; - } - - return this.editAndResubmitMode() ? step : null; - }) - .filter((step) => step !== null) - .map((step) => { - if (step.value !== PreprintSteps.AuthorAssertions) { - return step; + .filter((step) => { + if (step.value === PreprintSteps.File) { + return this.editAndResubmitMode(); } - - if (!provider.assertionsEnabled || !this.currentUserIsAdmin()) { - return null; + if (step.value === PreprintSteps.AuthorAssertions) { + return provider.assertionsEnabled && this.hasAdminAccess(); } - - return step; + return true; }) - .filter((step) => step !== null) - .map((step, index) => ({ - ...step, - index, - })); + .map((step, index) => ({ ...step, index })); }); currentStep = signal(submitPreprintSteps[0]); isWeb = toSignal(inject(IS_WEB)); - readonly SubmitStepsEnum = PreprintSteps; - readonly PreprintSteps = PreprintSteps; - constructor() { + this.actions.getPreprintProviderById(this.providerId()); + this.actions.fetchPreprint(this.preprintId()); + effect(() => { const provider = this.preprintProvider(); @@ -156,20 +141,16 @@ export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDea } @HostListener('window:beforeunload', ['$event']) - onBeforeUnload($event: BeforeUnloadEvent): boolean { - $event.preventDefault(); - return false; + onBeforeUnload($event: BeforeUnloadEvent): void { + if (!this.canDeactivate()) { + $event.preventDefault(); + } } - canDeactivate(): Observable | boolean { + canDeactivate(): boolean { return this.hasBeenSubmitted() || this.preprint()?.reviewsState === ReviewsState.Accepted; } - ngOnInit() { - this.actions.getPreprintProviderById(this.providerId()); - this.actions.fetchPreprint(this.preprintId()); - } - ngOnDestroy() { this.headerStyleHelper.resetToDefaults(); this.brandService.resetBranding(); @@ -178,19 +159,26 @@ export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDea } stepChange(step: StepOption): void { - const currentStepIndex = this.currentStep()?.index ?? 0; - if (step.index >= currentStepIndex) { + if (step.index >= this.currentStep().index) { return; } this.currentStep.set(step); } - moveToNextStep() { - this.currentStep.set(this.updateSteps()[this.currentStep()?.index + 1]); + moveToNextStep(): void { + const nextStep = this.updateSteps()[this.currentStep().index + 1]; + + if (nextStep) { + this.currentStep.set(nextStep); + } } - moveToPreviousStep() { - this.currentStep.set(this.updateSteps()[this.currentStep()?.index - 1]); + moveToPreviousStep(): void { + const prevStep = this.updateSteps()[this.currentStep().index - 1]; + + if (prevStep) { + this.currentStep.set(prevStep); + } } } diff --git a/src/app/features/preprints/preprints.component.spec.ts b/src/app/features/preprints/preprints.component.spec.ts index 48f6ec95d..7ae801441 100644 --- a/src/app/features/preprints/preprints.component.spec.ts +++ b/src/app/features/preprints/preprints.component.spec.ts @@ -1,23 +1,25 @@ +import { MockProvider } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HelpScoutService } from '@core/services/help-scout.service'; import { PreprintsComponent } from './preprints.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { HelpScoutServiceMockFactory } from '@testing/providers/help-scout.service.mock'; describe('Component: Preprint', () => { let fixture: ComponentFixture; let helpScoutService: HelpScoutService; - beforeEach(async () => { + beforeEach(() => { helpScoutService = HelpScoutServiceMockFactory(); - await TestBed.configureTestingModule({ - imports: [PreprintsComponent, OSFTestingModule], - providers: [{ provide: HelpScoutService, useValue: helpScoutService }], - }).compileComponents(); + TestBed.configureTestingModule({ + imports: [PreprintsComponent], + providers: [provideOSFCore(), MockProvider(HelpScoutService, helpScoutService)], + }); helpScoutService = TestBed.inject(HelpScoutService); fixture = TestBed.createComponent(PreprintsComponent); diff --git a/src/app/features/preprints/preprints.routes.ts b/src/app/features/preprints/preprints.routes.ts index 71732e2fb..0d650e913 100644 --- a/src/app/features/preprints/preprints.routes.ts +++ b/src/app/features/preprints/preprints.routes.ts @@ -31,7 +31,7 @@ export const preprintsRoutes: Routes = [ { path: 'discover', loadComponent: () => - import('@osf/features/preprints/pages/landing/preprints-landing.component').then( + import('@osf/features/preprints/pages/preprints-landing/preprints-landing.component').then( (c) => c.PreprintsLandingComponent ), }, diff --git a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.selectors.ts b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.selectors.ts index 09b9cb09a..43f5e74c6 100644 --- a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.selectors.ts +++ b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.selectors.ts @@ -1,6 +1,7 @@ import { Selector } from '@ngxs/store'; import { PreprintStepperState, PreprintStepperStateModel } from '@osf/features/preprints/store/preprint-stepper'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; export class PreprintStepperSelectors { @Selector([PreprintStepperState]) @@ -88,6 +89,11 @@ export class PreprintStepperSelectors { return state.hasBeenSubmitted; } + @Selector([PreprintStepperState]) + static hasAdminAccess(state: PreprintStepperStateModel) { + return state.preprint.data?.currentUserPermissions.includes(UserPermissions.Admin) || false; + } + @Selector([PreprintStepperState]) static getCurrentFolder(state: PreprintStepperStateModel) { return state.currentFolder.data; diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html index a79223195..cc671860b 100644 --- a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html @@ -5,7 +5,11 @@ } @else { - Provider Logo + } diff --git a/src/testing/providers/brand-service.mock.ts b/src/testing/providers/brand-service.mock.ts new file mode 100644 index 000000000..5aac5d574 --- /dev/null +++ b/src/testing/providers/brand-service.mock.ts @@ -0,0 +1,15 @@ +import { BrandService } from '@osf/shared/services/brand.service'; + +export type BrandServiceMockType = Partial & { + applyBranding: jest.Mock; + resetBranding: jest.Mock; +}; + +export const BrandServiceMock = { + simple(): BrandServiceMockType { + return { + applyBranding: jest.fn(), + resetBranding: jest.fn(), + }; + }, +}; diff --git a/src/testing/providers/browser-tab-service.mock.ts b/src/testing/providers/browser-tab-service.mock.ts new file mode 100644 index 000000000..ea74d6c5d --- /dev/null +++ b/src/testing/providers/browser-tab-service.mock.ts @@ -0,0 +1,15 @@ +import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; + +export type BrowserTabServiceMockType = Partial & { + updateTabStyles: jest.Mock; + resetToDefaults: jest.Mock; +}; + +export const BrowserTabServiceMock = { + simple(): BrowserTabServiceMockType { + return { + updateTabStyles: jest.fn(), + resetToDefaults: jest.fn(), + }; + }, +}; diff --git a/src/testing/providers/header-style-service.mock.ts b/src/testing/providers/header-style-service.mock.ts new file mode 100644 index 000000000..9e4e31848 --- /dev/null +++ b/src/testing/providers/header-style-service.mock.ts @@ -0,0 +1,15 @@ +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; + +export type HeaderStyleServiceMockType = Partial & { + applyHeaderStyles: jest.Mock; + resetToDefaults: jest.Mock; +}; + +export const HeaderStyleServiceMock = { + simple(): HeaderStyleServiceMockType { + return { + applyHeaderStyles: jest.fn(), + resetToDefaults: jest.fn(), + }; + }, +}; From d1ea7f7f84942086aa4986b5ca6fc756d940accb Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 25 Feb 2026 15:39:06 +0200 Subject: [PATCH 2/3] test(preprint-pages): added condition for provider --- .../mappers/preprint-providers.mapper.ts | 4 +- .../create-new-version.component.html | 22 ++++--- .../preprints-landing.component.html | 6 +- .../submit-preprint-stepper.component.html | 58 +++++++++---------- .../update-preprint-stepper.component.html | 58 +++++++++---------- .../update-preprint-stepper.component.ts | 11 ++-- .../features/preprints/preprints.routes.ts | 19 +++--- 7 files changed, 90 insertions(+), 88 deletions(-) diff --git a/src/app/features/preprints/mappers/preprint-providers.mapper.ts b/src/app/features/preprints/mappers/preprint-providers.mapper.ts index dbddfde22..c9fa25ac2 100644 --- a/src/app/features/preprints/mappers/preprint-providers.mapper.ts +++ b/src/app/features/preprints/mappers/preprint-providers.mapper.ts @@ -57,8 +57,8 @@ export class PreprintProvidersMapper { ): PreprintProviderShortInfo[] { return response.map((item) => ({ id: item.id, - descriptionHtml: item.attributes.description, - name: item.attributes.name, + name: replaceBadEncodedChars(item.attributes.name), + descriptionHtml: replaceBadEncodedChars(item.attributes.description), whiteWideImageUrl: item.attributes.assets?.wide_white, squareColorNoTransparentImageUrl: item.attributes.assets?.square_color_no_transparent, })); diff --git a/src/app/features/preprints/pages/create-new-version/create-new-version.component.html b/src/app/features/preprints/pages/create-new-version/create-new-version.component.html index c91017461..cbd1794d8 100644 --- a/src/app/features/preprints/pages/create-new-version/create-new-version.component.html +++ b/src/app/features/preprints/pages/create-new-version/create-new-version.component.html @@ -30,13 +30,17 @@

}

-
- @switch (currentStep().value) { - @case (PreprintSteps.File) { - - } - @case (PreprintSteps.Review) { - +@let provider = preprintProvider(); + +@if (provider) { +
+ @switch (currentStep().value) { + @case (PreprintSteps.File) { + + } + @case (PreprintSteps.Review) { + + } } - } -
+
+} diff --git a/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.html b/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.html index c1090b5d0..10735e837 100644 --- a/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.html +++ b/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.html @@ -57,11 +57,7 @@

{{ 'preprints.title' | translate }}

- +
}
-
- @switch (currentStep().value) { - @case (PreprintSteps.TitleAndAbstract) { - - } - @case (PreprintSteps.File) { - - } - @case (PreprintSteps.Metadata) { - - } - @case (PreprintSteps.AuthorAssertions) { - - } - @case (PreprintSteps.Supplements) { - - } - @case (PreprintSteps.Review) { - +@let provider = preprintProvider(); + +@if (provider) { +
+ @switch (currentStep().value) { + @case (PreprintSteps.TitleAndAbstract) { + + } + @case (PreprintSteps.File) { + + } + @case (PreprintSteps.Metadata) { + + } + @case (PreprintSteps.AuthorAssertions) { + + } + @case (PreprintSteps.Supplements) { + + } + @case (PreprintSteps.Review) { + + } } - } -
+
+} diff --git a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html index adefb2ec5..6a82b8ad0 100644 --- a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html +++ b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html @@ -30,33 +30,33 @@

} -
- @switch (currentStep().value) { - @case (PreprintSteps.TitleAndAbstract) { - - } - @case (PreprintSteps.File) { - - } - @case (PreprintSteps.Metadata) { - - } - @case (PreprintSteps.AuthorAssertions) { - - } - @case (PreprintSteps.Supplements) { - - } - @case (PreprintSteps.Review) { - +@let provider = preprintProvider(); + +@if (provider) { +
+ @switch (currentStep().value) { + @case (PreprintSteps.TitleAndAbstract) { + + } + @case (PreprintSteps.File) { + + } + @case (PreprintSteps.Metadata) { + + } + @case (PreprintSteps.AuthorAssertions) { + + } + @case (PreprintSteps.Supplements) { + + } + @case (PreprintSteps.Review) { + + } } - } -
+
+} diff --git a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts index c3756fc22..455e67cb9 100644 --- a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts +++ b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts @@ -81,14 +81,18 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC fetchPreprint: FetchPreprintById, }); - readonly PreprintSteps = PreprintSteps; - preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); preprint = select(PreprintStepperSelectors.getPreprint); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); hasAdminAccess = select(PreprintStepperSelectors.hasAdminAccess); + isWeb = toSignal(inject(IS_WEB)); + + currentStep = signal(submitPreprintSteps[0]); + + readonly PreprintSteps = PreprintSteps; + editAndResubmitMode = computed(() => { const providerIsPremod = this.preprintProvider()?.reviewsWorkflow === ProviderReviewsWorkflow.PreModeration; const preprintIsRejected = this.preprint()?.reviewsState === ReviewsState.Rejected; @@ -117,9 +121,6 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC .map((step, index) => ({ ...step, index })); }); - currentStep = signal(submitPreprintSteps[0]); - isWeb = toSignal(inject(IS_WEB)); - constructor() { this.actions.getPreprintProviderById(this.providerId()); this.actions.fetchPreprint(this.preprintId()); diff --git a/src/app/features/preprints/preprints.routes.ts b/src/app/features/preprints/preprints.routes.ts index 0d650e913..77d2991a4 100644 --- a/src/app/features/preprints/preprints.routes.ts +++ b/src/app/features/preprints/preprints.routes.ts @@ -3,18 +3,19 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; import { authGuard } from '@core/guards/auth.guard'; -import { preprintsModeratorGuard } from '@osf/features/preprints/guards'; -import { PreprintsComponent } from '@osf/features/preprints/preprints.component'; -import { PreprintState } from '@osf/features/preprints/store/preprint'; -import { PreprintProvidersState } from '@osf/features/preprints/store/preprint-providers'; -import { PreprintStepperState } from '@osf/features/preprints/store/preprint-stepper'; -import { ConfirmLeavingGuard } from '@shared/guards'; -import { CitationsState } from '@shared/stores/citations'; -import { ProjectsState } from '@shared/stores/projects'; -import { SubjectsState } from '@shared/stores/subjects'; +import { ConfirmLeavingGuard } from '@osf/shared/guards'; +import { CitationsState } from '@osf/shared/stores/citations'; +import { ProjectsState } from '@osf/shared/stores/projects'; +import { SubjectsState } from '@osf/shared/stores/subjects'; import { PreprintModerationState } from '../moderation/store/preprint-moderation'; +import { preprintsModeratorGuard } from './guards/preprints-moderator.guard'; +import { PreprintState } from './store/preprint'; +import { PreprintProvidersState } from './store/preprint-providers'; +import { PreprintStepperState } from './store/preprint-stepper'; +import { PreprintsComponent } from './preprints.component'; + export const preprintsRoutes: Routes = [ { path: '', From 92307925291ac61ea20f48d7149702fc69a2a6db Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 25 Feb 2026 16:52:40 +0200 Subject: [PATCH 3/3] test(preprints-pages): removed unnecessary input --- .../preprint-provider-overview.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.html b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.html index e4757725c..6f4ef7bcf 100644 --- a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.html +++ b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.html @@ -10,6 +10,6 @@ [isProviderLoading]="isPreprintProviderLoading()" /> - +