From c1bbe45a1bfce22d6c9b808f20c1e416a72d6475 Mon Sep 17 00:00:00 2001 From: nsemets Date: Tue, 24 Feb 2026 14:11:19 +0200 Subject: [PATCH 1/7] 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/7] 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 32deffb8405bc04167c23ad5ada04bb446f1a3e2 Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 25 Feb 2026 16:36:02 +0200 Subject: [PATCH 3/7] test(structured-clone): added mock for structured clone and clean up local mocks --- setup-jest.ts | 8 ++++++++ .../contributors/contributors.component.spec.ts | 10 ---------- .../contributors-dialog.component.spec.ts | 10 ---------- .../moderators-list/moderators-list.component.spec.ts | 10 ---------- .../registries-contributors.component.spec.ts | 10 ---------- 5 files changed, 8 insertions(+), 40 deletions(-) diff --git a/setup-jest.ts b/setup-jest.ts index 0ec261638..9fbefed7c 100644 --- a/setup-jest.ts +++ b/setup-jest.ts @@ -52,3 +52,11 @@ jest.mock('@newrelic/browser-agent/loaders/browser-agent', () => ({ stop: jest.fn(), })), })); + +if (!globalThis.structuredClone) { + Object.defineProperty(globalThis, 'structuredClone', { + value: (value: T): T => JSON.parse(JSON.stringify(value)) as T, + writable: true, + configurable: true, + }); +} diff --git a/src/app/features/contributors/contributors.component.spec.ts b/src/app/features/contributors/contributors.component.spec.ts index 249c8e8b3..d47ee0199 100644 --- a/src/app/features/contributors/contributors.component.spec.ts +++ b/src/app/features/contributors/contributors.component.spec.ts @@ -33,16 +33,6 @@ describe('Component: Contributors', () => { const mockContributors: ContributorModel[] = [MOCK_CONTRIBUTOR, MOCK_CONTRIBUTOR_WITHOUT_HISTORY]; - beforeAll(() => { - if (typeof (globalThis as any).structuredClone !== 'function') { - Object.defineProperty(globalThis as any, 'structuredClone', { - configurable: true, - writable: true, - value: (o: unknown) => JSON.parse(JSON.stringify(o)), - }); - } - }); - beforeEach(async () => { jest.useFakeTimers(); diff --git a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.spec.ts b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.spec.ts index b1e61b48e..cbb3b0b73 100644 --- a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.spec.ts +++ b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.spec.ts @@ -28,16 +28,6 @@ describe('ContributorsDialogComponent', () => { const mockContributors: ContributorModel[] = [MOCK_CONTRIBUTOR]; - beforeAll(() => { - if (typeof (globalThis as any).structuredClone !== 'function') { - Object.defineProperty(globalThis as any, 'structuredClone', { - configurable: true, - writable: true, - value: (o: unknown) => JSON.parse(JSON.stringify(o)), - }); - } - }); - beforeEach(async () => { mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); diff --git a/src/app/features/moderation/components/moderators-list/moderators-list.component.spec.ts b/src/app/features/moderation/components/moderators-list/moderators-list.component.spec.ts index 1da622c33..f19023271 100644 --- a/src/app/features/moderation/components/moderators-list/moderators-list.component.spec.ts +++ b/src/app/features/moderation/components/moderators-list/moderators-list.component.spec.ts @@ -41,16 +41,6 @@ describe('ModeratorsListComponent', () => { const mockModerators: ModeratorModel[] = MOCK_MODERATORS; - beforeAll(() => { - if (typeof (globalThis as any).structuredClone !== 'function') { - Object.defineProperty(globalThis as any, 'structuredClone', { - configurable: true, - writable: true, - value: (o: unknown) => JSON.parse(JSON.stringify(o)), - }); - } - }); - beforeEach(async () => { mockActivatedRoute = ActivatedRouteMockBuilder.create() .withParams({ providerId: mockProviderId }) diff --git a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.spec.ts index aecb80277..703f8e7ba 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.spec.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.spec.ts @@ -55,16 +55,6 @@ describe('RegistriesContributorsComponent', () => { const initialContributors: ContributorModel[] = [MOCK_CONTRIBUTOR, MOCK_CONTRIBUTOR_WITHOUT_HISTORY]; - beforeAll(() => { - if (typeof (globalThis as any).structuredClone !== 'function') { - Object.defineProperty(globalThis as any, 'structuredClone', { - configurable: true, - writable: true, - value: (o: unknown) => JSON.parse(JSON.stringify(o)), - }); - } - }); - beforeEach(() => { mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); From 92307925291ac61ea20f48d7149702fc69a2a6db Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 25 Feb 2026 16:52:40 +0200 Subject: [PATCH 4/7] 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()" /> - + From cbeb1153aa5c19bb2c6c23a1edade470b78b4afb Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 26 Feb 2026 15:57:14 +0200 Subject: [PATCH 5/7] test(preprints-components): added tests for stepper components --- .../array-input/array-input.component.html | 2 +- .../array-input/array-input.component.spec.ts | 69 +-- .../array-input/array-input.component.ts | 16 +- .../author-assertions-step.component.html | 4 +- .../author-assertions-step.component.spec.ts | 305 ++++++++++--- .../author-assertions-step.component.ts | 73 ++-- .../file-step/file-step.component.html | 30 +- .../file-step/file-step.component.spec.ts | 399 ++++++++++++------ .../stepper/file-step/file-step.component.ts | 47 +-- ...nts-affiliated-institutions.component.html | 6 +- ...-affiliated-institutions.component.spec.ts | 177 ++++---- ...rints-affiliated-institutions.component.ts | 31 +- .../preprints-contributors.component.spec.ts | 279 +++++++++--- .../preprints-contributors.component.ts | 53 ++- .../preprints-metadata-step.component.html | 39 +- .../preprints-metadata-step.component.spec.ts | 267 ++++++++---- .../preprints-metadata-step.component.ts | 56 ++- .../preprints-subjects.component.spec.ts | 188 +++------ .../preprints-subjects.component.ts | 42 +- .../review-step/review-step.component.html | 36 +- .../review-step/review-step.component.spec.ts | 255 ++++++++--- .../review-step/review-step.component.ts | 83 ++-- .../supplements-step.component.html | 26 +- .../supplements-step.component.spec.ts | 390 +++++++++++++---- .../supplements-step.component.ts | 41 +- .../title-and-abstract-step.component.html | 2 +- .../title-and-abstract-step.component.spec.ts | 170 +++----- .../title-and-abstract-step.component.ts | 39 +- .../create-new-version.component.html | 2 +- .../create-new-version.component.ts | 18 +- .../select-preprint-service.component.spec.ts | 22 +- .../select-preprint-service.component.ts | 11 +- .../submit-preprint-stepper.component.html | 2 +- .../submit-preprint-stepper.component.ts | 9 +- .../update-preprint-stepper.component.html | 4 +- .../update-preprint-stepper.component.ts | 9 +- .../preprint-stepper.actions.ts | 8 +- .../preprint-stepper.model.ts | 3 - .../preprint-stepper.selectors.ts | 5 - .../preprint-stepper.state.ts | 12 +- src/assets/i18n/en.json | 15 +- src/styles/overrides/select.scss | 6 + 42 files changed, 2055 insertions(+), 1196 deletions(-) diff --git a/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.html b/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.html index e29db002e..dd2c13d9b 100644 --- a/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.html +++ b/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.html @@ -19,6 +19,6 @@
- +
diff --git a/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.spec.ts b/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.spec.ts index 6af60f53a..c9c25c917 100644 --- a/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.spec.ts +++ b/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.spec.ts @@ -12,52 +12,49 @@ import { OSFTestingModule } from '@testing/osf.testing.module'; describe('ArrayInputComponent', () => { let component: ArrayInputComponent; let fixture: ComponentFixture; - let formArray: FormArray; + let formArray: FormArray>; - beforeEach(async () => { - await TestBed.configureTestingModule({ + function setup(overrides?: { withValidators?: boolean; formArray?: FormArray> }) { + TestBed.configureTestingModule({ imports: [ArrayInputComponent, MockComponent(TextInputComponent), OSFTestingModule], - }).compileComponents(); + }); fixture = TestBed.createComponent(ArrayInputComponent); component = fixture.componentInstance; - formArray = new FormArray([new FormControl('test')]); - fixture.componentRef.setInput('formArray', formArray); + formArray = + overrides?.formArray ?? new FormArray>([new FormControl('test', { nonNullable: true })]); + fixture.componentRef.setInput('formArray', formArray as FormArray); fixture.componentRef.setInput('inputPlaceholder', 'Enter value'); - fixture.componentRef.setInput('validators', [Validators.required]); + if (overrides?.withValidators ?? true) { + fixture.componentRef.setInput('validators', [Validators.required]); + } fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should have correct input values', () => { - expect(component.formArray()).toBe(formArray); - expect(component.inputPlaceholder()).toBe('Enter value'); - expect(component.validators()).toEqual([Validators.required]); - }); + } it('should add new control to form array', () => { + setup(); const initialLength = formArray.length; component.add(); expect(formArray.length).toBe(initialLength + 1); - expect(formArray.at(formArray.length - 1)).toBeInstanceOf(FormControl); + const newControl = formArray.at(formArray.length - 1); + expect(newControl.value).toBe(''); + expect(newControl.hasError('required')).toBe(true); }); - it('should add control with correct validators', () => { + it('should add control without validators when validators input is not set', () => { + setup({ withValidators: false }); component.add(); const newControl = formArray.at(formArray.length - 1); - expect(newControl.hasError('required')).toBe(true); + expect(newControl.errors).toBeNull(); }); it('should remove control at specified index', () => { - component.add(); + setup(); component.add(); const initialLength = formArray.length; @@ -67,9 +64,8 @@ describe('ArrayInputComponent', () => { }); it('should not remove control if only one control exists', () => { - const singleControlArray = new FormArray([new FormControl('only')]); - fixture.componentRef.setInput('formArray', singleControlArray); - fixture.detectChanges(); + const singleControlArray = new FormArray>([new FormControl('only', { nonNullable: true })]); + setup({ formArray: singleControlArray }); const initialLength = singleControlArray.length; @@ -77,27 +73,4 @@ describe('ArrayInputComponent', () => { expect(singleControlArray.length).toBe(initialLength); }); - - it('should handle multiple add and remove operations', () => { - const initialLength = formArray.length; - - component.add(); - component.add(); - component.add(); - - expect(formArray.length).toBe(initialLength + 3); - - component.remove(1); - component.remove(2); - - expect(formArray.length).toBe(initialLength + 1); - }); - - it('should create controls with nonNullable true', () => { - component.add(); - - const newControl = formArray.at(formArray.length - 1); - expect(newControl.value).toBe(''); - expect(newControl.hasError('required')).toBe(true); - }); }); diff --git a/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.ts b/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.ts index 4e32dd133..024eb0a03 100644 --- a/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.ts +++ b/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.ts @@ -15,11 +15,11 @@ import { TextInputComponent } from '@osf/shared/components/text-input/text-input changeDetection: ChangeDetectionStrategy.OnPush, }) export class ArrayInputComponent { - formArray = input.required>(); - inputPlaceholder = input.required(); - validators = input.required(); + readonly formArray = input.required>(); + readonly inputPlaceholder = input.required(); + readonly validators = input([]); - add() { + add(): void { this.formArray().push( new FormControl('', { nonNullable: true, @@ -28,9 +28,11 @@ export class ArrayInputComponent { ); } - remove(index: number) { - if (this.formArray().length > 1) { - this.formArray().removeAt(index); + remove(index: number): void { + const formArray = this.formArray(); + + if (formArray.length > 1) { + formArray.removeAt(index); } } } diff --git a/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.html b/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.html index 31c97a19d..6840ebace 100644 --- a/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.html +++ b/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.html @@ -224,7 +224,7 @@

{{ 'preprints.preprintStepper.authorAssertions.publicPreregistration.title' styleClass="w-full" [label]="'common.buttons.back' | translate" severity="info" - (click)="backButtonClicked()" + (onClick)="backButtonClicked()" /> {{ 'preprints.preprintStepper.authorAssertions.publicPreregistration.title' tooltipPosition="top" [disabled]="authorAssertionsForm.invalid" [loading]="isUpdatingPreprint()" - (click)="nextButtonClicked()" + (onClick)="nextButtonClicked()" /> diff --git a/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.spec.ts b/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.spec.ts index bba0d23d6..b75224444 100644 --- a/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.spec.ts +++ b/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.spec.ts @@ -1,10 +1,15 @@ -import { MockComponents, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponents, MockDirective, MockProvider } from 'ng-mocks'; + +import { Textarea } from 'primeng/textarea'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl } from '@angular/forms'; -import { ApplicabilityStatus } from '@osf/features/preprints/enums'; +import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums'; import { PreprintModel } from '@osf/features/preprints/models'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; +import { PreprintStepperSelectors, UpdatePreprint } from '@osf/features/preprints/store/preprint-stepper'; import { FormSelectComponent } from '@osf/shared/components/form-select/form-select.component'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; @@ -13,75 +18,280 @@ import { ArrayInputComponent } from './array-input/array-input.component'; import { AuthorAssertionsStepComponent } from './author-assertions-step.component'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; -import { TranslationServiceMock } from '@testing/mocks/translation.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('AuthorAssertionsStepComponent', () => { let component: AuthorAssertionsStepComponent; let fixture: ComponentFixture; - let toastServiceMock: ReturnType; - let customConfirmationServiceMock: ReturnType; + let store: Store; + let toastServiceMock: ToastServiceMockType; + let customConfirmationServiceMock: CustomConfirmationServiceMockType; const mockPreprint: PreprintModel = PREPRINT_MOCK; - beforeEach(async () => { - toastServiceMock = ToastServiceMockBuilder.create().build(); - customConfirmationServiceMock = CustomConfirmationServiceMockBuilder.create().build(); + const populatedPreprint: PreprintModel = { + ...mockPreprint, + hasCoi: true, + coiStatement: 'Author is a board member of the funder.', + hasDataLinks: ApplicabilityStatus.Applicable, + dataLinks: ['https://data.example/ds1', 'https://data.example/ds2'], + whyNoData: null, + hasPreregLinks: ApplicabilityStatus.Applicable, + preregLinks: ['https://prereg.example/reg1'], + whyNoPrereg: null, + preregLinkInfo: PreregLinkInfo.Both, + }; + + const cleanPreprint: PreprintModel = { + ...mockPreprint, + hasCoi: false, + coiStatement: null, + hasDataLinks: ApplicabilityStatus.NotApplicable, + dataLinks: [], + whyNoData: null, + hasPreregLinks: ApplicabilityStatus.NotApplicable, + preregLinks: [], + whyNoPrereg: null, + preregLinkInfo: null, + }; + + const defaultSignals: SignalOverride[] = [ + { selector: PreprintStepperSelectors.getPreprint, value: mockPreprint }, + { selector: PreprintStepperSelectors.isPreprintSubmitting, value: false }, + ]; + + function setup(overrides?: { selectorOverrides?: SignalOverride[]; detectChanges?: boolean }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + toastServiceMock = ToastServiceMock.simple(); + customConfirmationServiceMock = CustomConfirmationServiceMock.simple(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ AuthorAssertionsStepComponent, - OSFTestingModule, - MockComponents(ArrayInputComponent, FormSelectComponent), + ...MockComponents(ArrayInputComponent, FormSelectComponent), + MockDirective(Textarea), ], providers: [ - TranslationServiceMock, + provideOSFCore(), MockProvider(ToastService, toastServiceMock), MockProvider(CustomConfirmationService, customConfirmationServiceMock), - provideMockStore({ - signals: [ - { - selector: PreprintStepperSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintStepperSelectors.isPreprintSubmitting, - value: false, - }, - ], - }), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(AuthorAssertionsStepComponent); component = fixture.componentInstance; + if (overrides?.detectChanges ?? false) { + fixture.detectChanges(); + } + } + + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); }); - it('should create', () => { + it('should create and initialize form with preprint defaults', () => { + setup(); + expect(component).toBeTruthy(); + expect(component.authorAssertionsForm.controls.hasCoi.value).toBe(false); + expect(component.authorAssertionsForm.controls.coiStatement.value).toBeNull(); + expect(component.authorAssertionsForm.controls.hasDataLinks.value).toBe(ApplicabilityStatus.NotApplicable); + expect(component.authorAssertionsForm.controls.hasPreregLinks.value).toBe(ApplicabilityStatus.NotApplicable); + expect(component.hasCoiValue()).toBe(false); + expect(component.hasDataLinks()).toBe(ApplicabilityStatus.NotApplicable); + expect(component.hasPreregLinks()).toBe(ApplicabilityStatus.NotApplicable); + }); + + it('should hydrate form from a preprint that has real data', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: populatedPreprint }], + }); + + const controls = component.authorAssertionsForm.controls; + expect(controls.hasCoi.value).toBe(true); + expect(controls.coiStatement.value).toBe('Author is a board member of the funder.'); + expect(controls.hasDataLinks.value).toBe(ApplicabilityStatus.Applicable); + expect(controls.dataLinks.length).toBe(2); + expect(controls.hasPreregLinks.value).toBe(ApplicabilityStatus.Applicable); + expect(controls.preregLinks.length).toBe(1); + expect(controls.preregLinkInfo.value).toBe(PreregLinkInfo.Both); + }); + + it('should enable coiStatement control when hasCoi becomes true', () => { + setup({ detectChanges: true }); + component.authorAssertionsForm.controls.hasCoi.setValue(true); + fixture.detectChanges(); + + expect(component.authorAssertionsForm.controls.coiStatement.enabled).toBe(true); }); - it('should initialize form with preprint data', () => { - expect(component.authorAssertionsForm.get('hasCoi')?.value).toBe(false); - expect(component.authorAssertionsForm.get('coiStatement')?.value).toBeNull(); - expect(component.authorAssertionsForm.get('hasDataLinks')?.value).toBe(ApplicabilityStatus.NotApplicable); - expect(component.authorAssertionsForm.get('hasPreregLinks')?.value).toBe(ApplicabilityStatus.NotApplicable); + it('should disable and clear coiStatement control when hasCoi becomes false', () => { + setup({ detectChanges: true }); + const { hasCoi, coiStatement } = component.authorAssertionsForm.controls; + hasCoi.setValue(true); + coiStatement.setValue('Some statement'); + fixture.detectChanges(); + + hasCoi.setValue(false); + fixture.detectChanges(); + + expect(coiStatement.value).toBeNull(); + expect(coiStatement.disabled).toBe(true); + }); + + it('should enable whyNoData and clear dataLinks when hasDataLinks is Unavailable', () => { + setup({ detectChanges: true }); + const { hasDataLinks, whyNoData, dataLinks } = component.authorAssertionsForm.controls; + dataLinks.push(new FormControl('https://existing.example')); + + hasDataLinks.setValue(ApplicabilityStatus.Unavailable); + fixture.detectChanges(); + + expect(whyNoData.enabled).toBe(true); + expect(dataLinks.length).toBe(0); + }); + + it('should add an empty dataLinks entry and clear whyNoData when hasDataLinks is Applicable', () => { + setup({ detectChanges: true }); + const { hasDataLinks, whyNoData, dataLinks } = component.authorAssertionsForm.controls; + hasDataLinks.setValue(ApplicabilityStatus.Unavailable); + whyNoData.setValue('No data available'); + fixture.detectChanges(); + + hasDataLinks.setValue(ApplicabilityStatus.Applicable); + fixture.detectChanges(); + + expect(dataLinks.length).toBe(1); + expect(whyNoData.value).toBeNull(); + }); + + it('should enable whyNoPrereg and clear preregLinks/preregLinkInfo when hasPreregLinks is Unavailable', () => { + setup({ detectChanges: true }); + const { hasPreregLinks, whyNoPrereg, preregLinkInfo, preregLinks } = component.authorAssertionsForm.controls; + preregLinks.push(new FormControl('https://existing.example')); + preregLinkInfo.setValue(PreregLinkInfo.Both); + + hasPreregLinks.setValue(ApplicabilityStatus.Unavailable); + fixture.detectChanges(); + + expect(whyNoPrereg.enabled).toBe(true); + expect(preregLinks.length).toBe(0); + expect(preregLinkInfo.value).toBeNull(); + }); + + it('should add an empty preregLinks entry and enable preregLinkInfo when hasPreregLinks is Applicable', () => { + setup({ detectChanges: true }); + const { hasPreregLinks, preregLinks, preregLinkInfo } = component.authorAssertionsForm.controls; + hasPreregLinks.setValue(ApplicabilityStatus.Unavailable); + fixture.detectChanges(); + + hasPreregLinks.setValue(ApplicabilityStatus.Applicable); + fixture.detectChanges(); + + expect(preregLinks.length).toBe(1); + expect(preregLinkInfo.enabled).toBe(true); }); - it('should emit nextClicked when nextButtonClicked is called', () => { + it('should return early in nextButtonClicked when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + }); const emitSpy = jest.spyOn(component.nextClicked, 'emit'); + (store.dispatch as jest.Mock).mockClear(); + + component.nextButtonClicked(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdatePreprint)); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should dispatch UpdatePreprint, show success toast, and emit next on valid submission', () => { + setup(); + const emitSpy = jest.spyOn(component.nextClicked, 'emit'); + component.authorAssertionsForm.patchValue({ + hasCoi: true, + coiStatement: 'COI', + hasDataLinks: ApplicabilityStatus.Applicable, + hasPreregLinks: ApplicabilityStatus.Applicable, + preregLinkInfo: PreregLinkInfo.Both, + }); + component.authorAssertionsForm.controls.dataLinks.push(new FormControl('https://data.example')); + component.authorAssertionsForm.controls.preregLinks.push(new FormControl('https://prereg.example')); + (store.dispatch as jest.Mock).mockClear(); + component.nextButtonClicked(); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdatePreprint(mockPreprint.id, { + hasCoi: true, + coiStatement: 'COI', + hasDataLinks: ApplicabilityStatus.Applicable, + whyNoData: null, + dataLinks: ['https://data.example'], + hasPreregLinks: ApplicabilityStatus.Applicable, + whyNoPrereg: null, + preregLinks: ['https://prereg.example'], + preregLinkInfo: PreregLinkInfo.Both, + }) + ); expect(toastServiceMock.showSuccess).toHaveBeenCalledWith( 'preprints.preprintStepper.common.successMessages.preprintSaved' ); expect(emitSpy).toHaveBeenCalled(); }); - it('should show confirmation dialog when backButtonClicked is called with changes', () => { + it('should omit preregLinkInfo from the UpdatePreprint payload when it is empty', () => { + setup(); + component.authorAssertionsForm.patchValue({ + hasPreregLinks: ApplicabilityStatus.Applicable, + preregLinkInfo: null, + }); + component.authorAssertionsForm.controls.preregLinks.push(new FormControl('https://prereg.example')); + (store.dispatch as jest.Mock).mockClear(); + + component.nextButtonClicked(); + + expect(store.dispatch).toHaveBeenCalledWith( + new UpdatePreprint(mockPreprint.id, expect.objectContaining({ preregLinkInfo: undefined })) + ); + }); + + it('should return early in backButtonClicked when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + }); + const emitSpy = jest.spyOn(component.backClicked, 'emit'); + + component.backButtonClicked(); + + expect(customConfirmationServiceMock.confirmContinue).not.toHaveBeenCalled(); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should emit back immediately when there are no unsaved changes', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: cleanPreprint }], + }); + const emitSpy = jest.spyOn(component.backClicked, 'emit'); + + component.backButtonClicked(); + + expect(customConfirmationServiceMock.confirmContinue).not.toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should handle discard confirmation callbacks when there are unsaved changes', () => { + setup(); + const emitSpy = jest.spyOn(component.backClicked, 'emit'); component.authorAssertionsForm.patchValue({ hasCoi: true }); component.backButtonClicked(); @@ -92,20 +302,13 @@ describe('AuthorAssertionsStepComponent', () => { onConfirm: expect.any(Function), onReject: expect.any(Function), }); - }); - it('should expose readonly properties', () => { - expect(component.CustomValidators).toBeDefined(); - expect(component.ApplicabilityStatus).toBe(ApplicabilityStatus); - expect(component.inputLimits).toBeDefined(); - expect(component.INPUT_VALIDATION_MESSAGES).toBeDefined(); - expect(component.preregLinkOptions).toBeDefined(); - expect(component.linkValidators).toBeDefined(); - }); + const { onReject } = customConfirmationServiceMock.confirmContinue.mock.calls[0][0]; + onReject(); + expect(emitSpy).not.toHaveBeenCalled(); - it('should have correct signal values', () => { - expect(component.hasCoiValue()).toBe(false); - expect(component.hasDataLinks()).toBe(ApplicabilityStatus.NotApplicable); - expect(component.hasPreregLinks()).toBe(ApplicabilityStatus.NotApplicable); + const { onConfirm } = customConfirmationServiceMock.confirmContinue.mock.calls[0][0]; + onConfirm(); + expect(emitSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.ts b/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.ts index 97ca4a946..e8838344a 100644 --- a/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.ts +++ b/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.ts @@ -40,89 +40,88 @@ import { ArrayInputComponent } from './array-input/array-input.component'; @Component({ selector: 'osf-author-assertions-step', imports: [ + Button, Card, - FormsModule, + Message, RadioButton, - ReactiveFormsModule, Textarea, - Message, - TranslatePipe, - NgClass, - Button, Tooltip, + NgClass, + FormsModule, + ReactiveFormsModule, ArrayInputComponent, FormSelectComponent, + TranslatePipe, ], templateUrl: './author-assertions-step.component.html', styleUrl: './author-assertions-step.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AuthorAssertionsStepComponent { - private toastService = inject(ToastService); - private confirmationService = inject(CustomConfirmationService); - private actions = createDispatchMap({ updatePreprint: UpdatePreprint }); + private readonly toastService = inject(ToastService); + private readonly confirmationService = inject(CustomConfirmationService); + private readonly actions = createDispatchMap({ updatePreprint: UpdatePreprint }); - readonly CustomValidators = CustomValidators; readonly ApplicabilityStatus = ApplicabilityStatus; readonly inputLimits = formInputLimits; readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; readonly preregLinkOptions = preregLinksOptions; readonly linkValidators = [CustomValidators.linkValidator(), CustomValidators.requiredTrimmed()]; - createdPreprint = select(PreprintStepperSelectors.getPreprint); - isUpdatingPreprint = select(PreprintStepperSelectors.isPreprintSubmitting); + readonly createdPreprint = select(PreprintStepperSelectors.getPreprint); + readonly isUpdatingPreprint = select(PreprintStepperSelectors.isPreprintSubmitting); readonly authorAssertionsForm = new FormGroup({ - hasCoi: new FormControl(this.createdPreprint()!.hasCoi || false, { + hasCoi: new FormControl(this.createdPreprint()?.hasCoi ?? false, { nonNullable: true, validators: [], }), - coiStatement: new FormControl(this.createdPreprint()!.coiStatement, { + coiStatement: new FormControl(this.createdPreprint()?.coiStatement ?? null, { nonNullable: false, validators: [], }), hasDataLinks: new FormControl( - this.createdPreprint()!.hasDataLinks || ApplicabilityStatus.NotApplicable, + this.createdPreprint()?.hasDataLinks ?? ApplicabilityStatus.NotApplicable, { nonNullable: true, validators: [], } ), dataLinks: new FormArray( - this.createdPreprint()!.dataLinks?.map((link) => new FormControl(link)) || [] + this.createdPreprint()?.dataLinks?.map((link) => new FormControl(link)) || [] ), - whyNoData: new FormControl(this.createdPreprint()!.whyNoData, { + whyNoData: new FormControl(this.createdPreprint()?.whyNoData ?? null, { nonNullable: false, validators: [], }), hasPreregLinks: new FormControl( - this.createdPreprint()!.hasPreregLinks || ApplicabilityStatus.NotApplicable, + this.createdPreprint()?.hasPreregLinks ?? ApplicabilityStatus.NotApplicable, { nonNullable: true, validators: [], } ), preregLinks: new FormArray( - this.createdPreprint()!.preregLinks?.map((link) => new FormControl(link)) || [] + this.createdPreprint()?.preregLinks?.map((link) => new FormControl(link)) || [] ), - whyNoPrereg: new FormControl(this.createdPreprint()!.whyNoPrereg, { + whyNoPrereg: new FormControl(this.createdPreprint()?.whyNoPrereg ?? null, { nonNullable: false, validators: [], }), - preregLinkInfo: new FormControl(this.createdPreprint()!.preregLinkInfo, { + preregLinkInfo: new FormControl(this.createdPreprint()?.preregLinkInfo ?? null, { nonNullable: false, validators: [], }), }); hasCoiValue = toSignal(this.authorAssertionsForm.controls['hasCoi'].valueChanges, { - initialValue: this.createdPreprint()!.hasCoi || false, + initialValue: this.createdPreprint()?.hasCoi ?? false, }); hasDataLinks = toSignal(this.authorAssertionsForm.controls['hasDataLinks'].valueChanges, { - initialValue: this.createdPreprint()!.hasDataLinks || ApplicabilityStatus.NotApplicable, + initialValue: this.createdPreprint()?.hasDataLinks ?? ApplicabilityStatus.NotApplicable, }); hasPreregLinks = toSignal(this.authorAssertionsForm.controls['hasPreregLinks'].valueChanges, { - initialValue: this.createdPreprint()!.hasPreregLinks || ApplicabilityStatus.NotApplicable, + initialValue: this.createdPreprint()?.hasPreregLinks ?? ApplicabilityStatus.NotApplicable, }); nextClicked = output(); @@ -194,7 +193,13 @@ export class AuthorAssertionsStepComponent { }); } - nextButtonClicked() { + nextButtonClicked(): void { + const preprintId = this.createdPreprint()?.id; + + if (!preprintId) { + return; + } + const formValue = this.authorAssertionsForm.getRawValue(); const hasCoi = formValue.hasCoi; @@ -210,7 +215,7 @@ export class AuthorAssertionsStepComponent { const preregLinkInfo = formValue.preregLinkInfo || undefined; this.actions - .updatePreprint(this.createdPreprint()!.id, { + .updatePreprint(preprintId, { hasCoi, coiStatement, hasDataLinks, @@ -229,9 +234,15 @@ export class AuthorAssertionsStepComponent { }); } - backButtonClicked() { + backButtonClicked(): void { + const preprint = this.createdPreprint(); + + if (!preprint) { + return; + } + const formValue = this.authorAssertionsForm.getRawValue(); - const changedFields = findChangedFields(formValue, this.createdPreprint()!); + const changedFields = findChangedFields(formValue, preprint); if (!Object.keys(changedFields).length) { this.backClicked.emit(); @@ -248,7 +259,7 @@ export class AuthorAssertionsStepComponent { }); } - private disableAndClearValidators(control: AbstractControl) { + private disableAndClearValidators(control: AbstractControl): void { if (control instanceof FormArray) { while (control.length !== 0) { control.removeAt(0); @@ -261,12 +272,12 @@ export class AuthorAssertionsStepComponent { control.disable(); } - private enableAndSetValidators(control: AbstractControl, validators: ValidatorFn[]) { + private enableAndSetValidators(control: AbstractControl, validators: ValidatorFn[]): void { control.setValidators(validators); control.enable(); } - private addAtLeastOneControl(formArray: FormArray) { + private addAtLeastOneControl(formArray: FormArray): void { if (formArray.controls.length > 0) return; formArray.push( diff --git a/src/app/features/preprints/components/stepper/file-step/file-step.component.html b/src/app/features/preprints/components/stepper/file-step/file-step.component.html index 8a95bdcec..ee8b37860 100644 --- a/src/app/features/preprints/components/stepper/file-step/file-step.component.html +++ b/src/app/features/preprints/components/stepper/file-step/file-step.component.html @@ -4,7 +4,7 @@

{{ 'preprints.preprintStepper.file.title' | translate }}

{{ 'preprints.preprintStepper.file.uploadDescription' - | translate: { preprintWord: provider()?.preprintWord | titlecase } + | translate: { preprintWord: provider().preprintWord | titlecase } }}

{{ 'preprints.preprintStepper.file.note' | translate }}

@@ -56,7 +56,7 @@

{{ 'preprints.preprintStepper.file.title' | translate }}

(onClick)="fileInput.click()" /> - + } } @@ -64,25 +64,27 @@

{{ 'preprints.preprintStepper.file.title' | translate }}

@if (selectedFileSource() === PreprintFileSource.Project && !preprintFile() && !isPreprintFileLoading()) {
-

{{ 'preprints.preprintStepper.file.projectSelection.description' | translate }}

- {{ 'preprints.preprintStepper.file.projectSelection.subDescription' | translate }} + {{ 'preprints.preprintStepper.projectSelection.description' | translate }} +

+

+ {{ 'preprints.preprintStepper.projectSelection.subDescription' | translate }}

@@ -154,11 +156,9 @@

{{ 'preprints.preprintStepper.file.title' | translate }}

class="w-6 md:w-9rem" styleClass="w-full" [label]="'common.buttons.next' | translate" - [disabled]="!preprintFile() || versionFileMode()" + [disabled]="!canProceedToNext()" [pTooltip]=" - !preprintFile() || versionFileMode() - ? ('preprints.preprintStepper.common.validation.fillRequiredFields' | translate) - : '' + !canProceedToNext() ? ('preprints.preprintStepper.common.validation.fillRequiredFields' | translate) : '' " tooltipPosition="top" (onClick)="nextButtonClicked()" diff --git a/src/app/features/preprints/components/stepper/file-step/file-step.component.spec.ts b/src/app/features/preprints/components/stepper/file-step/file-step.component.spec.ts index 1471c9a39..0885ad9da 100644 --- a/src/app/features/preprints/components/stepper/file-step/file-step.component.spec.ts +++ b/src/app/features/preprints/components/stepper/file-step/file-step.component.spec.ts @@ -1,120 +1,202 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SelectChangeEvent } from 'primeng/select'; + +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { PreprintFileSource } from '@osf/features/preprints/enums'; import { PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/models'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; +import { + CopyFileFromProject, + FetchAvailableProjects, + FetchPreprintFilesLinks, + FetchPreprintPrimaryFile, + FetchProjectFilesByLink, + PreprintStepperSelectors, + ReuploadFile, + SetPreprintStepperCurrentFolder, + SetProjectRootFolder, + SetSelectedPreprintFileSource, + UploadFile, +} from '@osf/features/preprints/store/preprint-stepper'; import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; +import { FileModel } from '@osf/shared/models/files/file.model'; +import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { FileFolderModel } from '@shared/models/files/file-folder.model'; import { FileStepComponent } from './file-step.component'; import { OSF_FILE_MOCK } from '@testing/mocks/osf-file.mock'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('FileStepComponent', () => { let component: FileStepComponent; let fixture: ComponentFixture; - let toastServiceMock: ReturnType; - let confirmationServiceMock: ReturnType; + let store: Store; + let toastServiceMock: ToastServiceMockType; + let confirmationServiceMock: CustomConfirmationServiceMockType; + const originalPointerEvent = (globalThis as unknown as { PointerEvent?: typeof Event }).PointerEvent; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; const mockPreprint: PreprintModel = PREPRINT_MOCK; const mockProjectFiles: FileFolderModel[] = [OSF_FILE_MOCK]; const mockPreprintFile: FileFolderModel = OSF_FILE_MOCK; - + const mockCurrentFolder: FileFolderModel = OSF_FILE_MOCK; const mockAvailableProjects = [ - { id: 'project-1', title: 'Test Project 1' }, - { id: 'project-2', title: 'Test Project 2' }, + { id: 'project-1', name: 'Test Project 1' }, + { id: 'project-2', name: 'Test Project 2' }, ]; - beforeEach(async () => { - toastServiceMock = ToastServiceMockBuilder.create().build(); - confirmationServiceMock = CustomConfirmationServiceMockBuilder.create().build(); + const defaultSignals: SignalOverride[] = [ + { selector: PreprintStepperSelectors.getPreprint, value: mockPreprint }, + { selector: PreprintStepperSelectors.getSelectedFileSource, value: PreprintFileSource.None }, + { selector: PreprintStepperSelectors.getUploadLink, value: 'upload-link' }, + { selector: PreprintStepperSelectors.getPreprintFile, value: mockPreprintFile }, + { selector: PreprintStepperSelectors.isPreprintFilesLoading, value: false }, + { selector: PreprintStepperSelectors.getAvailableProjects, value: mockAvailableProjects }, + { selector: PreprintStepperSelectors.areAvailableProjectsLoading, value: false }, + { selector: PreprintStepperSelectors.getProjectFiles, value: mockProjectFiles }, + { selector: PreprintStepperSelectors.getFilesTotalCount, value: 1 }, + { selector: PreprintStepperSelectors.areProjectFilesLoading, value: false }, + { selector: PreprintStepperSelectors.getCurrentFolder, value: mockCurrentFolder }, + { selector: PreprintStepperSelectors.isCurrentFolderLoading, value: false }, + ]; - await TestBed.configureTestingModule({ - imports: [FileStepComponent, ...MockComponents(IconComponent, FilesTreeComponent), OSFTestingModule], + function setup(overrides?: { + selectorOverrides?: SignalOverride[]; + provider?: PreprintProviderDetails; + detectChanges?: boolean; + }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + toastServiceMock = ToastServiceMock.simple(); + confirmationServiceMock = CustomConfirmationServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [FileStepComponent, ...MockComponents(IconComponent, FilesTreeComponent)], providers: [ + provideOSFCore(), MockProvider(ToastService, toastServiceMock), MockProvider(CustomConfirmationService, confirmationServiceMock), - provideMockStore({ - signals: [ - { - selector: PreprintStepperSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintStepperSelectors.getSelectedProviderId, - value: 'provider-1', - }, - { - selector: PreprintStepperSelectors.getSelectedFileSource, - value: PreprintFileSource.None, - }, - { - selector: PreprintStepperSelectors.getUploadLink, - value: 'upload-link', - }, - { - selector: PreprintStepperSelectors.getPreprintFile, - value: mockPreprintFile, - }, - { - selector: PreprintStepperSelectors.isPreprintFilesLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.getAvailableProjects, - value: mockAvailableProjects, - }, - { - selector: PreprintStepperSelectors.areAvailableProjectsLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.getProjectFiles, - value: mockProjectFiles, - }, - { - selector: PreprintStepperSelectors.areProjectFilesLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.getCurrentFolder, - value: null, - }, - ], - }), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(FileStepComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('provider', mockProvider); - fixture.detectChanges(); + fixture.componentRef.setInput('provider', overrides?.provider ?? mockProvider); + if (overrides?.detectChanges ?? true) { + fixture.detectChanges(); + } + } + + beforeAll(() => { + if (!(globalThis as unknown as { PointerEvent?: typeof Event }).PointerEvent) { + (globalThis as unknown as { PointerEvent: typeof Event }).PointerEvent = MouseEvent as unknown as typeof Event; + } + }); + + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); + }); + + afterAll(() => { + if (originalPointerEvent) { + (globalThis as unknown as { PointerEvent: typeof Event }).PointerEvent = originalPointerEvent; + } else { + delete (globalThis as unknown as { PointerEvent?: typeof Event }).PointerEvent; + } }); it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should initialize with correct values', () => { - expect(component.provider()).toBe(mockProvider); - expect(component.preprint()).toBe(mockPreprint); - expect(component.selectedFileSource()).toBe(PreprintFileSource.None); - expect(component.preprintFile()).toBe(mockPreprintFile); + it('should compute state values', () => { + setup({ detectChanges: false }); + expect(component.preprintHasPrimaryFile()).toBe(true); + expect(component.isFileSourceSelected()).toBe(false); + expect(component.canProceedToNext()).toBe(true); + expect(component.cancelSourceOptionButtonVisible()).toBe(false); }); - it('should emit backClicked when backButtonClicked is called', () => { + it('should dispatch fetch links and primary file fetch in ngOnInit', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprintFile, value: null }], + detectChanges: false, + }); + + component.ngOnInit(); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintFilesLinks()); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintPrimaryFile()); + }); + + it('should not dispatch primary file fetch in ngOnInit without primary file id', () => { + setup({ + selectorOverrides: [ + { selector: PreprintStepperSelectors.getPreprint, value: { ...mockPreprint, primaryFileId: null } }, + ], + detectChanges: false, + }); + + component.ngOnInit(); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintFilesLinks()); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchPreprintPrimaryFile)); + }); + + it('should dispatch available projects from debounced projectNameControl value', fakeAsync(() => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + + component.projectNameControl.setValue('project-search'); + tick(500); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchAvailableProjects('project-search')); + })); + + it('should skip available projects dispatch when value equals selectedProjectId', fakeAsync(() => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + component.selectedProjectId.set('project-1'); + + component.projectNameControl.setValue('project-1'); + tick(500); + + expect(store.dispatch).not.toHaveBeenCalledWith(new FetchAvailableProjects('project-1')); + })); + + it('should handle selectFileSource for project and computer source', () => { + setup({ detectChanges: false }); + + component.selectFileSource(PreprintFileSource.Project); + expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedPreprintFileSource(PreprintFileSource.Project)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchAvailableProjects(null)); + + (store.dispatch as jest.Mock).mockClear(); + component.selectFileSource(PreprintFileSource.Computer); + + expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedPreprintFileSource(PreprintFileSource.Computer)); + expect(store.dispatch).not.toHaveBeenCalledWith(new FetchAvailableProjects(null)); + }); + + it('should emit backClicked', () => { + setup({ detectChanges: false }); const emitSpy = jest.spyOn(component.backClicked, 'emit'); component.backButtonClicked(); @@ -122,7 +204,8 @@ describe('FileStepComponent', () => { expect(emitSpy).toHaveBeenCalled(); }); - it('should emit nextClicked when nextButtonClicked is called with primary file', () => { + it('should handle nextButtonClicked for allowed and blocked states', () => { + setup({ detectChanges: false }); const emitSpy = jest.spyOn(component.nextClicked, 'emit'); component.nextButtonClicked(); @@ -130,82 +213,158 @@ describe('FileStepComponent', () => { expect(toastServiceMock.showSuccess).toHaveBeenCalledWith( 'preprints.preprintStepper.common.successMessages.preprintSaved' ); - expect(emitSpy).toHaveBeenCalled(); - }); + expect(emitSpy).toHaveBeenCalledTimes(1); - it('should not emit nextClicked when nextButtonClicked is called without primary file', () => { - const emitSpy = jest.spyOn(component.nextClicked, 'emit'); - jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, primaryFileId: null }); + component.versionFileMode.set(true); + toastServiceMock.showSuccess.mockClear(); component.nextButtonClicked(); - expect(emitSpy).not.toHaveBeenCalled(); + expect(toastServiceMock.showSuccess).not.toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledTimes(1); + + jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, primaryFileId: null } as PreprintModel); + component.versionFileMode.set(false); + + component.nextButtonClicked(); + + expect(toastServiceMock.showSuccess).not.toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledTimes(1); }); - it('should handle file selection for upload', () => { - const mockFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); - const mockEvent = { - target: { - files: [mockFile], - }, - } as any; + it('should skip file upload dispatches when no file is selected', () => { + setup({ detectChanges: false }); - component.onFileSelected(mockEvent); + const input = document.createElement('input'); + Object.defineProperty(input, 'files', { value: [] }); + component.onFileSelected({ target: input } as unknown as Event); - expect(mockFile).toBeDefined(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UploadFile)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(ReuploadFile)); }); - it('should handle file selection for reupload', () => { - component.versionFileMode.set(true); + it('should handle upload and reupload flows in onFileSelected', () => { + setup({ detectChanges: false }); + const file = new File(['file-body'], 'test.txt'); + const input = document.createElement('input'); + Object.defineProperty(input, 'files', { value: [file] }); - const mockFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); - const mockEvent = { - target: { - files: [mockFile], - }, - } as any; + component.onFileSelected({ target: input } as unknown as Event); + expect(store.dispatch).toHaveBeenCalledWith(new UploadFile(file)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintPrimaryFile()); + + (store.dispatch as jest.Mock).mockClear(); + component.versionFileMode.set(true); - component.onFileSelected(mockEvent); + component.onFileSelected({ target: input } as unknown as Event); + expect(store.dispatch).toHaveBeenCalledWith(new ReuploadFile(file)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintPrimaryFile()); expect(component.versionFileMode()).toBe(false); }); - it('should handle version file confirmation', () => { - confirmationServiceMock.confirmContinue.mockImplementation(({ onConfirm }) => { - onConfirm(); - }); + it('should handle selectProject with and without current folder files link', () => { + setup({ detectChanges: false }); + + component.selectProject({ + value: 'project-1', + originalEvent: new PointerEvent('click'), + } as SelectChangeEvent); + + expect(store.dispatch).toHaveBeenCalledWith(new SetProjectRootFolder('project-1')); + expect(store.dispatch).toHaveBeenCalledWith(new FetchProjectFilesByLink(mockCurrentFolder.links.filesLink, 1)); + expect(component.selectedProjectId()).toBe('project-1'); + + jest.spyOn(component, 'currentFolder').mockReturnValue(null); + (store.dispatch as jest.Mock).mockClear(); + + component.selectProject({ + value: 'project-1', + originalEvent: new PointerEvent('click'), + } as SelectChangeEvent); + + expect(store.dispatch).toHaveBeenCalledWith(new SetProjectRootFolder('project-1')); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchProjectFilesByLink)); + }); + + it('should return early in selectProject when original event is not pointer event', () => { + setup({ detectChanges: false }); + + component.selectProject({ + value: 'project-1', + originalEvent: new Event('change'), + } as SelectChangeEvent); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetProjectRootFolder)); + }); + + it('should dispatch copy file from project and preprint file fetch', () => { + setup({ detectChanges: false }); + const projectFile = OSF_FILE_MOCK as unknown as FileModel; + + component.selectProjectFile(projectFile); + + expect(store.dispatch).toHaveBeenCalledWith(new CopyFileFromProject(projectFile)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintPrimaryFile()); + }); + + it('should set version mode and reset selected source on version file confirmation', () => { + setup({ detectChanges: false }); component.versionFile(); + const options = confirmationServiceMock.confirmContinue.mock.calls[0][0]; + options.onConfirm(); - expect(confirmationServiceMock.confirmContinue).toHaveBeenCalledWith({ - headerKey: 'preprints.preprintStepper.file.versionFile.header', - messageKey: 'preprints.preprintStepper.file.versionFile.message', - onConfirm: expect.any(Function), - onReject: expect.any(Function), - }); expect(component.versionFileMode()).toBe(true); + expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedPreprintFileSource(PreprintFileSource.None)); }); - it('should handle cancel button click', () => { - jest.spyOn(component, 'preprintFile').mockReturnValue(null); + it('should not change mode or selected source on version file reject', () => { + setup({ detectChanges: false }); - component.cancelButtonClicked(); + component.versionFile(); + const options = confirmationServiceMock.confirmContinue.mock.calls[0][0]; + (store.dispatch as jest.Mock).mockClear(); + options.onReject(); - expect(component.preprintFile()).toBeNull(); + expect(component.versionFileMode()).toBe(false); + expect(store.dispatch).not.toHaveBeenCalledWith(new SetSelectedPreprintFileSource(PreprintFileSource.None)); }); - it('should not handle cancel button click when preprint file exists', () => { + it('should handle cancelButtonClicked for file present and file missing states', () => { + setup({ detectChanges: false }); + component.cancelButtonClicked(); + expect(store.dispatch).not.toHaveBeenCalledWith(new SetSelectedPreprintFileSource(PreprintFileSource.None)); - expect(component.preprintFile()).toBeDefined(); + jest.spyOn(component, 'preprintFile').mockReturnValue(null); + (store.dispatch as jest.Mock).mockClear(); + + component.cancelButtonClicked(); + expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedPreprintFileSource(PreprintFileSource.None)); }); - it('should expose readonly properties', () => { - expect(component.PreprintFileSource).toBe(PreprintFileSource); + it('should handle setCurrentFolder for unchanged and changed folders', () => { + setup({ detectChanges: false }); + + component.setCurrentFolder(mockCurrentFolder); + expect(store.dispatch).not.toHaveBeenCalledWith(new SetPreprintStepperCurrentFolder(mockCurrentFolder)); + expect(store.dispatch).not.toHaveBeenCalledWith(new FetchProjectFilesByLink(mockCurrentFolder.links.filesLink, 1)); + + (store.dispatch as jest.Mock).mockClear(); + const nextFolder = { ...mockCurrentFolder, id: 'folder-2' } as FileFolderModel; + + component.setCurrentFolder(nextFolder); + + expect(store.dispatch).toHaveBeenCalledWith(new SetPreprintStepperCurrentFolder(nextFolder)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchProjectFilesByLink(nextFolder.links.filesLink, 1)); }); - it('should have correct form control', () => { - expect(component.projectNameControl).toBeDefined(); - expect(component.projectNameControl.value).toBeNull(); + it('should dispatch files load in onLoadFiles', () => { + setup({ detectChanges: false }); + + component.onLoadFiles({ link: '/v2/nodes/node-456/files/', page: 3 }); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchProjectFilesByLink('/v2/nodes/node-456/files/', 3)); }); }); diff --git a/src/app/features/preprints/components/stepper/file-step/file-step.component.ts b/src/app/features/preprints/components/stepper/file-step/file-step.component.ts index 9f556128a..b8f771b61 100644 --- a/src/app/features/preprints/components/stepper/file-step/file-step.component.ts +++ b/src/app/features/preprints/components/stepper/file-step/file-step.component.ts @@ -42,6 +42,7 @@ import { } from '@osf/features/preprints/store/preprint-stepper'; import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; +import { ClearFileDirective } from '@osf/shared/directives/clear-file.directive'; import { StringOrNull } from '@osf/shared/helpers/types.helper'; import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; @@ -52,16 +53,17 @@ import { ToastService } from '@osf/shared/services/toast.service'; selector: 'osf-file-step', imports: [ Button, - TitleCasePipe, - NgClass, + Card, Tooltip, Skeleton, - IconComponent, - Card, Select, + NgClass, ReactiveFormsModule, + IconComponent, FilesTreeComponent, + TitleCasePipe, TranslatePipe, + ClearFileDirective, ], templateUrl: './file-step.component.html', styleUrl: './file-step.component.scss', @@ -70,6 +72,8 @@ import { ToastService } from '@osf/shared/services/toast.service'; export class FileStepComponent implements OnInit { private toastService = inject(ToastService); private customConfirmationService = inject(CustomConfirmationService); + private destroyRef = inject(DestroyRef); + private actions = createDispatchMap({ setSelectedFileSource: SetSelectedPreprintFileSource, getPreprintFilesLinks: FetchPreprintFilesLinks, @@ -82,13 +86,11 @@ export class FileStepComponent implements OnInit { copyFileFromProject: CopyFileFromProject, setCurrentFolder: SetPreprintStepperCurrentFolder, }); - private destroyRef = inject(DestroyRef); readonly PreprintFileSource = PreprintFileSource; - provider = input.required(); + provider = input.required(); preprint = select(PreprintStepperSelectors.getPreprint); - providerId = select(PreprintStepperSelectors.getSelectedProviderId); selectedFileSource = select(PreprintStepperSelectors.getSelectedFileSource); fileUploadLink = select(PreprintStepperSelectors.getUploadLink); @@ -120,12 +122,15 @@ export class FileStepComponent implements OnInit { backClicked = output(); isFileSourceSelected = computed(() => this.selectedFileSource() !== PreprintFileSource.None); + canProceedToNext = computed(() => !!this.preprintFile() && !this.versionFileMode()); ngOnInit() { this.actions.getPreprintFilesLinks(); + if (this.preprintHasPrimaryFile() && !this.preprintFile()) { this.actions.fetchPreprintFile(); } + this.projectNameControl.valueChanges .pipe(debounceTime(500), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) .subscribe((projectNameOrId) => { @@ -150,7 +155,7 @@ export class FileStepComponent implements OnInit { } nextButtonClicked() { - if (!this.preprint()?.primaryFileId) { + if (!this.canProceedToNext() || !this.preprint()?.primaryFileId) { return; } @@ -163,20 +168,14 @@ export class FileStepComponent implements OnInit { const file = input.files?.[0]; if (!file) return; - if (this.versionFileMode()) { + const isVersionFileMode = this.versionFileMode(); + + if (isVersionFileMode) { this.versionFileMode.set(false); - this.actions.reuploadFile(file).subscribe({ - next: () => { - this.actions.fetchPreprintFile(); - }, - }); - } else { - this.actions.uploadFile(file).subscribe({ - next: () => { - this.actions.fetchPreprintFile(); - }, - }); } + + const uploadAction = isVersionFileMode ? this.actions.reuploadFile(file) : this.actions.uploadFile(file); + uploadAction.subscribe(() => this.actions.fetchPreprintFile()); } selectProject(event: SelectChangeEvent) { @@ -185,6 +184,7 @@ export class FileStepComponent implements OnInit { } this.selectedProjectId.set(event.value); + this.actions .setProjectRootFolder(event.value) .pipe( @@ -201,11 +201,7 @@ export class FileStepComponent implements OnInit { } selectProjectFile(file: FileModel) { - this.actions.copyFileFromProject(file).subscribe({ - next: () => { - this.actions.fetchPreprintFile(); - }, - }); + this.actions.copyFileFromProject(file).subscribe(() => this.actions.fetchPreprintFile()); } versionFile() { @@ -232,6 +228,7 @@ export class FileStepComponent implements OnInit { if (this.currentFolder()?.id === folder.id) { return; } + this.actions.setCurrentFolder(folder); this.actions.getProjectFilesByLink(folder.links.filesLink, 1); } diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.html b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.html index 090082de5..8866ee45c 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.html +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.html @@ -6,16 +6,14 @@

{{ 'preprints.preprintStepper.metadata.affiliatedInstitutionsTitle' | transl class="mt-3" [innerHTML]=" 'preprints.preprintStepper.metadata.affiliatedInstitutionsDescription' - | translate: { preprintWord: provider()?.preprintWord } + | translate: { preprintWord: provider().preprintWord } " >

diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.spec.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.spec.ts index 68cff07db..13b3a6187 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.spec.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.spec.ts @@ -1,128 +1,149 @@ +import { Store } from '@ngxs/store'; + import { MockComponent } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ReviewsState } from '@osf/features/preprints/enums'; -import { PreprintProviderDetails } from '@osf/features/preprints/models'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; +import { PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/models'; +import { PreprintStepperSelectors, SetInstitutionsChanged } from '@osf/features/preprints/store/preprint-stepper'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { Institution } from '@osf/shared/models/institutions/institutions.model'; -import { InstitutionsSelectors } from '@shared/stores/institutions'; +import { + FetchResourceInstitutions, + FetchUserInstitutions, + InstitutionsSelectors, + UpdateResourceInstitutions, +} from '@shared/stores/institutions'; import { PreprintsAffiliatedInstitutionsComponent } from './preprints-affiliated-institutions.component'; import { MOCK_INSTITUTION } from '@testing/mocks/institution.mock'; +import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; describe('PreprintsAffiliatedInstitutionsComponent', () => { let component: PreprintsAffiliatedInstitutionsComponent; let fixture: ComponentFixture; + let store: Store; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; - const mockPreprint: any = { id: 'preprint-1', reviewsState: ReviewsState.Pending }; + const mockPreprint: PreprintModel = PREPRINT_MOCK; const mockUserInstitutions: Institution[] = [MOCK_INSTITUTION]; const mockResourceInstitutions: Institution[] = [MOCK_INSTITUTION]; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - PreprintsAffiliatedInstitutionsComponent, - OSFTestingModule, - MockComponent(AffiliatedInstitutionSelectComponent), - ], - providers: [ - provideMockStore({ - signals: [ - { - selector: InstitutionsSelectors.getUserInstitutions, - value: mockUserInstitutions, - }, - { - selector: InstitutionsSelectors.areUserInstitutionsLoading, - value: false, - }, - { - selector: InstitutionsSelectors.getResourceInstitutions, - value: mockResourceInstitutions, - }, - { - selector: InstitutionsSelectors.areResourceInstitutionsLoading, - value: false, - }, - { - selector: InstitutionsSelectors.areResourceInstitutionsSubmitting, - value: false, - }, - { - selector: PreprintStepperSelectors.getInstitutionsChanged, - value: false, - }, - ], - }), - ], - }).compileComponents(); - + const defaultSignals: SignalOverride[] = [ + { selector: InstitutionsSelectors.getUserInstitutions, value: mockUserInstitutions }, + { selector: InstitutionsSelectors.areUserInstitutionsLoading, value: false }, + { selector: InstitutionsSelectors.getResourceInstitutions, value: mockResourceInstitutions }, + { selector: InstitutionsSelectors.areResourceInstitutionsLoading, value: false }, + { selector: InstitutionsSelectors.areResourceInstitutionsSubmitting, value: false }, + { selector: PreprintStepperSelectors.getInstitutionsChanged, value: false }, + ]; + + function setup(overrides?: { + selectorOverrides?: SignalOverride[]; + preprint?: PreprintModel; + detectChanges?: boolean; + }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [PreprintsAffiliatedInstitutionsComponent, MockComponent(AffiliatedInstitutionSelectComponent)], + providers: [provideOSFCore(), provideMockStore({ signals })], + }); + + store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintsAffiliatedInstitutionsComponent); component = fixture.componentInstance; fixture.componentRef.setInput('provider', mockProvider); - fixture.componentRef.setInput('preprint', mockPreprint); - fixture.detectChanges(); + fixture.componentRef.setInput('preprint', overrides?.preprint ?? mockPreprint); + if (overrides?.detectChanges ?? true) { + fixture.detectChanges(); + } + } + + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); }); it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should initialize with correct values', () => { - expect(component.provider()).toBe(mockProvider); - expect(component.preprint()!.id).toBe('preprint-1'); - expect(component.userInstitutions()).toBe(mockUserInstitutions); - expect(component.areUserInstitutionsLoading()).toBe(false); - expect(component.resourceInstitutions()).toBe(mockResourceInstitutions); - expect(component.areResourceInstitutionsLoading()).toBe(false); - expect(component.areResourceInstitutionsSubmitting()).toBe(false); + it('should compute loading state when any loading flag is true', () => { + setup({ + selectorOverrides: [{ selector: InstitutionsSelectors.areResourceInstitutionsSubmitting, value: true }], + }); + expect(component.isLoading()).toBe(true); }); - it('should initialize selectedInstitutions with resource institutions', () => { + it('should initialize selected institutions from resource institutions effect', () => { + setup(); expect(component.selectedInstitutions()).toEqual(mockResourceInstitutions); }); - it('should handle institutions change', () => { - const newInstitutions = [MOCK_INSTITUTION]; + it('should keep selected institutions empty when resource institutions are empty', () => { + setup({ + selectorOverrides: [{ selector: InstitutionsSelectors.getResourceInstitutions, value: [] }], + }); + expect(component.selectedInstitutions()).toEqual([]); + }); - component.onInstitutionsChange(newInstitutions); + it('should dispatch fetch actions on init lifecycle', () => { + setup(); - expect(component.selectedInstitutions()).toEqual(newInstitutions); + expect(store.dispatch).toHaveBeenCalledWith(new FetchUserInstitutions()); + expect(store.dispatch).toHaveBeenCalledWith(new FetchResourceInstitutions(mockPreprint.id, ResourceType.Preprint)); }); - it('should handle effect for resource institutions', () => { - const newResourceInstitutions = [MOCK_INSTITUTION]; + it('should auto-apply user institutions on create flow when institutions not changed', () => { + setup({ + preprint: { ...mockPreprint, reviewsState: ReviewsState.Initial }, + }); - jest.spyOn(component, 'resourceInstitutions').mockReturnValue(newResourceInstitutions); - component.ngOnInit(); - - expect(component.selectedInstitutions()).toEqual(newResourceInstitutions); + expect(store.dispatch).toHaveBeenCalledWith(new SetInstitutionsChanged(true)); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateResourceInstitutions(mockPreprint.id, ResourceType.Preprint, mockUserInstitutions) + ); }); - it('should not update selectedInstitutions when resource institutions is empty', () => { - const initialInstitutions = component.selectedInstitutions(); + it('should not auto-apply user institutions when not in create flow', () => { + setup({ + preprint: { ...mockPreprint, reviewsState: ReviewsState.Pending }, + }); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetInstitutionsChanged)); + expect(store.dispatch).not.toHaveBeenCalledWith( + new UpdateResourceInstitutions(mockPreprint.id, ResourceType.Preprint, mockUserInstitutions) + ); + }); - jest.spyOn(component, 'resourceInstitutions').mockReturnValue([]); - component.ngOnInit(); + it('should not auto-apply user institutions when institutions already changed', () => { + setup({ + preprint: { ...mockPreprint, reviewsState: ReviewsState.Initial }, + selectorOverrides: [{ selector: PreprintStepperSelectors.getInstitutionsChanged, value: true }], + }); - expect(component.selectedInstitutions()).toEqual(initialInstitutions); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetInstitutionsChanged)); + expect(store.dispatch).not.toHaveBeenCalledWith( + new UpdateResourceInstitutions(mockPreprint.id, ResourceType.Preprint, mockUserInstitutions) + ); }); - it('should handle multiple institution changes', () => { - const firstChange = [MOCK_INSTITUTION]; - const secondChange = [MOCK_INSTITUTION]; + it('should dispatch update institutions on selection change', () => { + setup(); + const updatedInstitutions = [MOCK_INSTITUTION]; - component.onInstitutionsChange(firstChange); - expect(component.selectedInstitutions()).toEqual(firstChange); + component.onInstitutionsChange(updatedInstitutions); - component.onInstitutionsChange(secondChange); - expect(component.selectedInstitutions()).toEqual(secondChange); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateResourceInstitutions(mockPreprint.id, ResourceType.Preprint, updatedInstitutions) + ); }); }); diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.ts index fc8444c93..084d50f9e 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.ts @@ -4,7 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; -import { ChangeDetectionStrategy, Component, effect, input, OnInit, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, input, OnInit, signal } from '@angular/core'; import { ReviewsState } from '@osf/features/preprints/enums'; import { PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/models'; @@ -27,17 +27,17 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintsAffiliatedInstitutionsComponent implements OnInit { - provider = input.required(); - preprint = input.required(); + readonly provider = input.required(); + readonly preprint = input.required(); - selectedInstitutions = signal([]); + readonly selectedInstitutions = signal([]); - userInstitutions = select(InstitutionsSelectors.getUserInstitutions); - areUserInstitutionsLoading = select(InstitutionsSelectors.areUserInstitutionsLoading); - resourceInstitutions = select(InstitutionsSelectors.getResourceInstitutions); - areResourceInstitutionsLoading = select(InstitutionsSelectors.areResourceInstitutionsLoading); - areResourceInstitutionsSubmitting = select(InstitutionsSelectors.areResourceInstitutionsSubmitting); - institutionsChanged = select(PreprintStepperSelectors.getInstitutionsChanged); + readonly userInstitutions = select(InstitutionsSelectors.getUserInstitutions); + readonly areUserInstitutionsLoading = select(InstitutionsSelectors.areUserInstitutionsLoading); + readonly resourceInstitutions = select(InstitutionsSelectors.getResourceInstitutions); + readonly areResourceInstitutionsLoading = select(InstitutionsSelectors.areResourceInstitutionsLoading); + readonly areResourceInstitutionsSubmitting = select(InstitutionsSelectors.areResourceInstitutionsSubmitting); + readonly institutionsChanged = select(PreprintStepperSelectors.getInstitutionsChanged); private readonly actions = createDispatchMap({ fetchUserInstitutions: FetchUserInstitutions, @@ -46,6 +46,13 @@ export class PreprintsAffiliatedInstitutionsComponent implements OnInit { setInstitutionsChanged: SetInstitutionsChanged, }); + isLoading = computed( + () => + this.areUserInstitutionsLoading() || + this.areResourceInstitutionsLoading() || + this.areResourceInstitutionsSubmitting() + ); + constructor() { effect(() => { const resourceInstitutions = this.resourceInstitutions(); @@ -56,7 +63,7 @@ export class PreprintsAffiliatedInstitutionsComponent implements OnInit { effect(() => { const userInstitutions = this.userInstitutions(); - const isCreateFlow = this.preprint()?.reviewsState === ReviewsState.Initial; + const isCreateFlow = this.preprint().reviewsState === ReviewsState.Initial; if (userInstitutions.length > 0 && isCreateFlow && !this.institutionsChanged()) { this.actions.setInstitutionsChanged(true); @@ -67,7 +74,7 @@ export class PreprintsAffiliatedInstitutionsComponent implements OnInit { ngOnInit() { this.actions.fetchUserInstitutions(); - this.actions.fetchResourceInstitutions(this.preprint()!.id, ResourceType.Preprint); + this.actions.fetchResourceInstitutions(this.preprint().id, ResourceType.Preprint); } onInstitutionsChange(institutions: Institution[]): void { diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.spec.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.spec.ts index 6da10dfb3..1f79458fe 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.spec.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.spec.ts @@ -1,103 +1,258 @@ +import { Store } from '@ngxs/store'; + import { MockComponent, MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { UserSelectors } from '@core/store/user'; import { ContributorsTableComponent } from '@osf/shared/components/contributors'; +import { AddContributorType } from '@osf/shared/enums/contributors/add-contributor-type.enum'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { ContributorModel } from '@shared/models/contributors/contributor.model'; -import { ContributorsSelectors } from '@shared/stores/contributors'; +import { + BulkAddContributors, + BulkUpdateContributors, + ContributorsSelectors, + DeleteContributor, + GetAllContributors, + LoadMoreContributors, +} from '@shared/stores/contributors'; import { PreprintsContributorsComponent } from './preprints-contributors.component'; -import { MOCK_CONTRIBUTOR } from '@testing/mocks/contributors.mock'; -import { MOCK_USER } from '@testing/mocks/data.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { MOCK_CONTRIBUTOR, MOCK_CONTRIBUTOR_ADD } from '@testing/mocks/contributors.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { CustomDialogServiceMock, CustomDialogServiceMockType } from '@testing/providers/custom-dialog-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('PreprintsContributorsComponent', () => { let component: PreprintsContributorsComponent; let fixture: ComponentFixture; - let toastServiceMock: ReturnType; - let confirmationServiceMock: ReturnType; - let mockCustomDialogService: ReturnType; + let store: Store; + let dialogMock: CustomDialogServiceMockType; + let confirmationMock: CustomConfirmationServiceMockType; + let toastMock: ToastServiceMockType; - const mockContributors: ContributorModel[] = [MOCK_CONTRIBUTOR]; - const mockCurrentUser = MOCK_USER; + const mockContributors = [MOCK_CONTRIBUTOR]; + const preprintId = 'preprint-1'; + const defaultSignals: SignalOverride[] = [ + { selector: ContributorsSelectors.getContributors, value: mockContributors }, + { selector: ContributorsSelectors.isContributorsLoading, value: false }, + { selector: ContributorsSelectors.isContributorsLoadingMore, value: false }, + { selector: ContributorsSelectors.getContributorsPageSize, value: 10 }, + { selector: ContributorsSelectors.getContributorsTotalCount, value: 1 }, + ]; - beforeEach(async () => { - toastServiceMock = ToastServiceMockBuilder.create().build(); - confirmationServiceMock = CustomConfirmationServiceMockBuilder.create().build(); - mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); + function setup(overrides?: { + preprintId?: string; + selectorOverrides?: SignalOverride[]; + addDialogCloseValue?: unknown; + addUnregisteredDialogCloseValue?: unknown; + }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + const addDialogClose$ = of(overrides?.addDialogCloseValue); + const addUnregisteredDialogClose$ = of(overrides?.addUnregisteredDialogCloseValue); - await TestBed.configureTestingModule({ - imports: [PreprintsContributorsComponent, OSFTestingModule, MockComponent(ContributorsTableComponent)], + dialogMock = CustomDialogServiceMock.create() + .withOpen( + jest.fn((component: unknown) => { + const isUnregisteredDialog = + typeof component === 'function' && `${component}`.includes('AddUnregisteredContributorDialogComponent'); + return { + onClose: isUnregisteredDialog ? addUnregisteredDialogClose$ : addDialogClose$, + } as never; + }) + ) + .build(); + confirmationMock = CustomConfirmationServiceMock.simple(); + toastMock = ToastServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [PreprintsContributorsComponent, MockComponent(ContributorsTableComponent)], providers: [ - MockProvider(ToastService, toastServiceMock), - MockProvider(CustomConfirmationService, confirmationServiceMock), - MockProvider(CustomDialogService, mockCustomDialogService), - provideMockStore({ - signals: [ - { - selector: ContributorsSelectors.getContributors, - value: mockContributors, - }, - { - selector: ContributorsSelectors.isContributorsLoading, - value: false, - }, - { - selector: UserSelectors.getCurrentUser, - value: mockCurrentUser, - }, - ], - }), + provideOSFCore(), + MockProvider(CustomDialogService, dialogMock), + MockProvider(CustomConfirmationService, confirmationMock), + MockProvider(ToastService, toastMock), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintsContributorsComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('preprintId', 'preprint-1'); + fixture.componentRef.setInput( + 'preprintId', + overrides && 'preprintId' in overrides ? overrides.preprintId : preprintId + ); + fixture.detectChanges(); + } + + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); + }); + + it('should fetch contributors when preprint id exists', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new GetAllContributors(preprintId, ResourceType.Preprint)); + }); + + it('should clone initial contributors into editable contributors', () => { + setup(); + + expect(component.contributors()).toEqual(mockContributors); + expect(component.contributors()).not.toBe(mockContributors); + }); + + it('should compute hasChanges correctly', () => { + setup(); + expect(component.hasChanges).toBe(false); + + component.contributors.set([{ ...MOCK_CONTRIBUTOR, fullName: 'Updated Name' }]); + expect(component.hasChanges).toBe(true); + + component.contributors.set([]); + expect(component.hasChanges).toBe(true); + }); + + it('should compute table params from selector values', () => { + setup(); + + expect(component.tableParams().totalRecords).toBe(1); + expect(component.tableParams().rows).toBe(10); + expect(component.tableParams().paginator).toBe(false); + expect(component.tableParams().scrollable).toBe(true); + }); + + it('should reset contributors on cancel', () => { + setup(); + component.contributors.set([{ ...MOCK_CONTRIBUTOR, fullName: 'Changed' }]); + + component.cancel(); + + expect(component.contributors()).toEqual(mockContributors); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should save changed contributors and show success toast', () => { + setup(); + component.contributors.set([{ ...MOCK_CONTRIBUTOR, fullName: 'Updated Name' }]); + + component.save(); + + expect(store.dispatch).toHaveBeenCalledWith( + new BulkUpdateContributors(preprintId, ResourceType.Preprint, [{ ...MOCK_CONTRIBUTOR, fullName: 'Updated Name' }]) + ); + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'project.contributors.toastMessages.multipleUpdateSuccessMessage' + ); + }); + + it('should open add contributor dialog', () => { + setup(); + + component.openAddContributorDialog(); + + expect(dialogMock.open).toHaveBeenCalled(); }); - it('should remove contributor with confirmation', () => { - const contributorToRemove = mockContributors[0]; + it('should ignore empty add contributor dialog result', () => { + setup({ addDialogCloseValue: null }); - confirmationServiceMock.confirmDelete.mockImplementation(({ onConfirm }) => { - onConfirm(); + component.openAddContributorDialog(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(BulkAddContributors)); + }); + + it('should open unregistered dialog when add dialog returns unregistered type', () => { + setup({ + addDialogCloseValue: { type: AddContributorType.Unregistered, data: [MOCK_CONTRIBUTOR_ADD] }, + }); + const openUnregisteredSpy = jest.spyOn(component, 'openAddUnregisteredContributorDialog'); + + component.openAddContributorDialog(); + + expect(openUnregisteredSpy).toHaveBeenCalled(); + }); + + it('should add contributors when add dialog returns registered type', () => { + setup({ + addDialogCloseValue: { type: AddContributorType.Registered, data: [MOCK_CONTRIBUTOR_ADD] }, }); - component.removeContributor(contributorToRemove); + component.openAddContributorDialog(); + + expect(store.dispatch).toHaveBeenCalledWith( + new BulkAddContributors(preprintId, ResourceType.Preprint, [MOCK_CONTRIBUTOR_ADD]) + ); + expect(toastMock.showSuccess).toHaveBeenCalledWith('project.contributors.toastMessages.multipleAddSuccessMessage'); + }); - expect(confirmationServiceMock.confirmDelete).toHaveBeenCalledWith({ + it('should open registered dialog when unregistered dialog returns registered type', () => { + setup({ + addUnregisteredDialogCloseValue: { type: AddContributorType.Registered, data: [MOCK_CONTRIBUTOR_ADD] }, + }); + const openRegisteredSpy = jest.spyOn(component, 'openAddContributorDialog'); + + component.openAddUnregisteredContributorDialog(); + + expect(openRegisteredSpy).toHaveBeenCalled(); + }); + + it('should add unregistered contributor and show named toast', () => { + setup({ + addUnregisteredDialogCloseValue: { + type: AddContributorType.Unregistered, + data: [{ ...MOCK_CONTRIBUTOR_ADD, fullName: 'Jane Doe' }], + }, + }); + + component.openAddUnregisteredContributorDialog(); + + expect(store.dispatch).toHaveBeenCalledWith( + new BulkAddContributors(preprintId, ResourceType.Preprint, [{ ...MOCK_CONTRIBUTOR_ADD, fullName: 'Jane Doe' }]) + ); + expect(toastMock.showSuccess).toHaveBeenCalledWith('project.contributors.toastMessages.addSuccessMessage', { + name: 'Jane Doe', + }); + }); + + it('should open delete confirmation and delete contributor on confirm', () => { + setup(); + + component.removeContributor(MOCK_CONTRIBUTOR); + + expect(confirmationMock.confirmDelete).toHaveBeenCalledWith({ headerKey: 'project.contributors.removeDialog.title', messageKey: 'project.contributors.removeDialog.message', - messageParams: { name: contributorToRemove.fullName }, + messageParams: { name: MOCK_CONTRIBUTOR.fullName }, acceptLabelKey: 'common.buttons.remove', onConfirm: expect.any(Function), }); - }); - it('should expose readonly properties', () => { - expect(component.destroyRef).toBeDefined(); - expect(component.customDialogService).toBeDefined(); - expect(component.toastService).toBeDefined(); - expect(component.customConfirmationService).toBeDefined(); - expect(component.actions).toBeDefined(); - }); + const { onConfirm } = confirmationMock.confirmDelete.mock.calls[0][0]; + onConfirm(); - it('should handle effect for contributors', () => { - component.ngOnInit(); + expect(store.dispatch).toHaveBeenCalledWith( + new DeleteContributor(preprintId, ResourceType.Preprint, MOCK_CONTRIBUTOR.userId) + ); + expect(toastMock.showSuccess).toHaveBeenCalledWith('project.contributors.removeDialog.successMessage', { + name: MOCK_CONTRIBUTOR.fullName, + }); + }); - expect(component).toBeTruthy(); + it('should load more contributors', () => { + setup({ preprintId }); + component.loadMoreContributors(); + expect(store.dispatch).toHaveBeenCalledWith(new LoadMoreContributors(preprintId, ResourceType.Preprint)); }); }); diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts index 3fbfa30d6..aad0b7305 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts @@ -5,7 +5,6 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; -import { TableModule } from 'primeng/table'; import { filter } from 'rxjs'; @@ -21,7 +20,6 @@ import { signal, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; import { AddContributorDialogComponent, @@ -49,25 +47,33 @@ import { TableParameters } from '@shared/models/table-parameters.model'; @Component({ selector: 'osf-preprints-contributors', - imports: [FormsModule, TableModule, ContributorsTableComponent, TranslatePipe, Card, Button, Message], + imports: [Button, Card, Message, ContributorsTableComponent, TranslatePipe], templateUrl: './preprints-contributors.component.html', styleUrl: './preprints-contributors.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintsContributorsComponent implements OnInit { - preprintId = input(''); + readonly preprintId = input.required(); readonly destroyRef = inject(DestroyRef); readonly customDialogService = inject(CustomDialogService); readonly toastService = inject(ToastService); readonly customConfirmationService = inject(CustomConfirmationService); - initialContributors = select(ContributorsSelectors.getContributors); - contributors = signal([]); - contributorsTotalCount = select(ContributorsSelectors.getContributorsTotalCount); - isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); - isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); - pageSize = select(ContributorsSelectors.getContributorsPageSize); + readonly initialContributors = select(ContributorsSelectors.getContributors); + readonly contributors = signal([]); + readonly contributorsTotalCount = select(ContributorsSelectors.getContributorsTotalCount); + readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); + readonly isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); + readonly pageSize = select(ContributorsSelectors.getContributorsPageSize); + + readonly actions = createDispatchMap({ + getContributors: GetAllContributors, + deleteContributor: DeleteContributor, + bulkUpdateContributors: BulkUpdateContributors, + bulkAddContributors: BulkAddContributors, + loadMoreContributors: LoadMoreContributors, + }); readonly tableParams = computed(() => ({ ...DEFAULT_TABLE_PARAMS, @@ -78,14 +84,6 @@ export class PreprintsContributorsComponent implements OnInit { rows: this.pageSize(), })); - actions = createDispatchMap({ - getContributors: GetAllContributors, - deleteContributor: DeleteContributor, - bulkUpdateContributors: BulkUpdateContributors, - bulkAddContributors: BulkAddContributors, - loadMoreContributors: LoadMoreContributors, - }); - get hasChanges(): boolean { return JSON.stringify(this.initialContributors()) !== JSON.stringify(this.contributors()); } @@ -155,9 +153,12 @@ export class PreprintsContributorsComponent implements OnInit { } else { const params = { name: res.data[0].fullName }; - this.actions.bulkAddContributors(this.preprintId(), ResourceType.Preprint, res.data).subscribe({ - next: () => this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params), - }); + this.actions + .bulkAddContributors(this.preprintId(), ResourceType.Preprint, res.data) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => + this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params) + ); } }); } @@ -172,12 +173,10 @@ export class PreprintsContributorsComponent implements OnInit { this.actions .deleteContributor(this.preprintId(), ResourceType.Preprint, contributor.userId) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: () => { - this.toastService.showSuccess('project.contributors.removeDialog.successMessage', { - name: contributor.fullName, - }); - }, + .subscribe(() => { + this.toastService.showSuccess('project.contributors.removeDialog.successMessage', { + name: contributor.fullName, + }); }); }, }); diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.html b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.html index 3d20423c3..624123cc0 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.html +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.html @@ -1,8 +1,12 @@ +@let preprint = createdPreprint(); +

{{ 'preprints.preprintStepper.metadata.title' | translate }}

-
- -
+@if (preprint) { +
+ +
+}

{{ 'shared.license.title' | translate }}

@@ -14,6 +18,7 @@

{{ 'shared.license.title' | translate }}

{{ 'common.links.helpGuide' | translate }}.

+ {{ 'shared.license.title' | translate }}

/> -
- -
+@if (preprint) { +
+ +
-
- -
+
+ +
+}
@@ -47,10 +58,12 @@

{{ 'preprints.preprintStepper.metadata.tagsTitle' | translate }}

{{ 'preprints.preprintStepper.metadata.publicationDoi.title' | translate }}

+ @let doiControl = metadataForm.controls['doi']; + @if (doiControl.errors?.['pattern'] && (doiControl.touched || doiControl.dirty)) { - {{ 'preprints.preprintStepper.metadata.publicationDoi.patternError' | translate }} + + {{ 'preprints.preprintStepper.metadata.publicationDoi.patternError' | translate }} }
@@ -91,7 +104,7 @@

{{ 'preprints.preprintStepper.metadata.publicationCitationTitle styleClass="w-full" [label]="'common.buttons.back' | translate" severity="info" - (click)="backButtonClicked()" + (onClick)="backButtonClicked()" /> {{ 'preprints.preprintStepper.metadata.publicationCitationTitle tooltipPosition="top" [disabled]="metadataForm.invalid || !createdPreprint()?.licenseId" [loading]="isUpdatingPreprint()" - (click)="nextButtonClicked()" + (onClick)="nextButtonClicked()" /> diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.spec.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.spec.ts index ee60f9c14..19f1b0559 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.spec.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.spec.ts @@ -1,18 +1,24 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormControl, FormGroup } from '@angular/forms'; -import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { formInputLimits } from '@osf/features/preprints/constants'; -import { PreprintProviderDetails } from '@osf/features/preprints/models'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; +import { PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/models'; +import { + FetchLicenses, + PreprintStepperSelectors, + SaveLicense, + UpdatePreprint, +} from '@osf/features/preprints/store/preprint-stepper'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LicenseComponent } from '@osf/shared/components/license/license.component'; import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { LicenseModel } from '@shared/models/license/license.model'; import { PreprintsAffiliatedInstitutionsComponent } from './preprints-affiliated-institutions/preprints-affiliated-institutions.component'; import { PreprintsContributorsComponent } from './preprints-contributors/preprints-contributors.component'; @@ -22,31 +28,39 @@ import { PreprintsMetadataStepComponent } from './preprints-metadata-step.compon import { MOCK_LICENSE } from '@testing/mocks/license.mock'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('PreprintsMetadataStepComponent', () => { let component: PreprintsMetadataStepComponent; let fixture: ComponentFixture; - let toastServiceMock: ReturnType; - let customConfirmationServiceMock: ReturnType; + let store: Store; + let toastServiceMock: ToastServiceMockType; + let customConfirmationServiceMock: CustomConfirmationServiceMockType; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; - const mockPreprint = PREPRINT_MOCK; - const mockLicenses = [MOCK_LICENSE]; + const mockPreprint: PreprintModel = PREPRINT_MOCK; + const mockLicenses: LicenseModel[] = [MOCK_LICENSE]; + + const defaultSignals: SignalOverride[] = [ + { selector: PreprintStepperSelectors.getLicenses, value: mockLicenses }, + { selector: PreprintStepperSelectors.getPreprint, value: mockPreprint }, + { selector: PreprintStepperSelectors.isPreprintSubmitting, value: false }, + ]; - beforeEach(async () => { - toastServiceMock = ToastServiceMockBuilder.create().withShowSuccess(jest.fn()).build(); - customConfirmationServiceMock = CustomConfirmationServiceMockBuilder.create() - .withConfirmContinue(jest.fn()) - .build(); + function setup(overrides?: { selectorOverrides?: SignalOverride[]; detectChanges?: boolean }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + toastServiceMock = ToastServiceMock.simple(); + customConfirmationServiceMock = CustomConfirmationServiceMock.simple(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ PreprintsMetadataStepComponent, - OSFTestingModule, ...MockComponents( PreprintsContributorsComponent, IconComponent, @@ -57,125 +71,204 @@ describe('PreprintsMetadataStepComponent', () => { ), ], providers: [ + provideOSFCore(), MockProvider(ToastService, toastServiceMock), MockProvider(CustomConfirmationService, customConfirmationServiceMock), - provideNoopAnimations(), - provideMockStore({ - signals: [ - { - selector: PreprintStepperSelectors.getLicenses, - value: mockLicenses, - }, - { - selector: PreprintStepperSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintStepperSelectors.isPreprintSubmitting, - value: false, - }, - ], - }), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintsMetadataStepComponent); component = fixture.componentInstance; fixture.componentRef.setInput('provider', mockProvider); - fixture.detectChanges(); + if (overrides?.detectChanges ?? true) { + fixture.detectChanges(); + } + } + + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); }); it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should initialize with correct default values', () => { + it('should initialize defaults and form values', () => { + setup(); + expect(component.inputLimits).toBe(formInputLimits); expect(component.INPUT_VALIDATION_MESSAGES).toBe(INPUT_VALIDATION_MESSAGES); expect(component.today).toBeInstanceOf(Date); + expect(component.metadataForm.controls.doi.value).toBe(mockPreprint.doi); + expect(component.metadataForm.controls.customPublicationCitation.value).toBe( + mockPreprint.customPublicationCitation + ); + expect(component.metadataForm.controls.tags.value).toEqual(mockPreprint.tags); }); - it('should initialize form with correct structure', () => { - fixture.detectChanges(); - expect(component.metadataForm).toBeInstanceOf(FormGroup); - expect(component.metadataForm.controls['doi']).toBeInstanceOf(FormControl); - expect(component.metadataForm.controls['originalPublicationDate']).toBeInstanceOf(FormControl); - expect(component.metadataForm.controls['customPublicationCitation']).toBeInstanceOf(FormControl); - expect(component.metadataForm.controls['tags']).toBeInstanceOf(FormControl); - expect(component.metadataForm.controls['subjects']).toBeInstanceOf(FormControl); + it('should dispatch fetch licenses on init', () => { + setup(); + expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses(mockProvider.id)); }); - it('should return licenses from store', () => { - const licenses = component.licenses(); - expect(licenses).toBe(mockLicenses); - }); + it('should auto-select default license and dispatch save when license has no required fields', () => { + const licenseWithoutFields = { ...MOCK_LICENSE, id: 'license-no-fields', requiredFields: [] }; + setup({ + selectorOverrides: [ + { selector: PreprintStepperSelectors.getLicenses, value: [licenseWithoutFields] }, + { + selector: PreprintStepperSelectors.getPreprint, + value: { ...mockPreprint, licenseId: null, defaultLicenseId: 'license-no-fields' }, + }, + ], + }); - it('should return created preprint from store', () => { - const preprint = component.createdPreprint(); - expect(preprint).toBe(mockPreprint); + expect(component.defaultLicense()).toBe('license-no-fields'); + expect(store.dispatch).toHaveBeenCalledWith(new SaveLicense('license-no-fields', undefined)); }); - it('should return submission state from store', () => { - const isSubmitting = component.isUpdatingPreprint(); - expect(isSubmitting).toBe(false); - }); + it('should auto-select default license without dispatching save when license requires fields', () => { + setup({ + selectorOverrides: [ + { selector: PreprintStepperSelectors.getLicenses, value: [MOCK_LICENSE] }, + { + selector: PreprintStepperSelectors.getPreprint, + value: { ...mockPreprint, licenseId: null, defaultLicenseId: MOCK_LICENSE.id }, + }, + ], + }); - it('should return provider input', () => { - const provider = component.provider(); - expect(provider).toBe(mockProvider); + expect(component.defaultLicense()).toBe(MOCK_LICENSE.id); + expect(store.dispatch).not.toHaveBeenCalledWith(new SaveLicense(MOCK_LICENSE.id, undefined)); }); - it('should handle next button click with valid form', () => { - fixture.detectChanges(); + it('should return early in nextButtonClicked when form is invalid', () => { + setup(); const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); + (store.dispatch as jest.Mock).mockClear(); - component.metadataForm.patchValue({ - subjects: [{ id: 'subject1', name: 'Test Subject' }], + component.metadataForm.patchValue({ subjects: [] }); + component.nextButtonClicked(); + + expect(nextClickedSpy).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdatePreprint)); + }); + + it('should return early in nextButtonClicked when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + detectChanges: false, }); + component.initForm(); + component.metadataForm.patchValue({ subjects: [{ id: 'subject-1', name: 'Subject 1' }] }); + const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); + (store.dispatch as jest.Mock).mockClear(); component.nextButtonClicked(); - expect(nextClickedSpy).toHaveBeenCalled(); + expect(nextClickedSpy).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdatePreprint)); }); - it('should not proceed with next button click when form is invalid', () => { - fixture.detectChanges(); + it('should update preprint and emit success in nextButtonClicked', () => { + setup(); const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); - - component.metadataForm.patchValue({ - subjects: [], - }); + component.metadataForm.patchValue({ subjects: [{ id: 'subject-1', name: 'Subject 1' }] }); + (store.dispatch as jest.Mock).mockClear(); component.nextButtonClicked(); - expect(nextClickedSpy).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(UpdatePreprint)); + expect(toastServiceMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.common.successMessages.preprintSaved' + ); + expect(nextClickedSpy).toHaveBeenCalled(); + }); + + it('should dispatch save license from createLicense', () => { + setup({ detectChanges: false }); + component.createLicense({ id: MOCK_LICENSE.id, licenseOptions: { year: '2024', copyrightHolders: 'A' } }); + expect(store.dispatch).toHaveBeenCalledWith( + new SaveLicense(MOCK_LICENSE.id, { year: '2024', copyrightHolders: 'A' }) + ); + }); + + it('should dispatch save license in selectLicense only when required fields are absent', () => { + setup({ detectChanges: false }); + const noFields = { ...MOCK_LICENSE, id: 'no-fields', requiredFields: [] }; + (store.dispatch as jest.Mock).mockClear(); + + component.selectLicense(noFields); + expect(store.dispatch).toHaveBeenCalledWith(new SaveLicense('no-fields', undefined)); + + (store.dispatch as jest.Mock).mockClear(); + component.selectLicense(MOCK_LICENSE); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SaveLicense)); + }); + + it('should update tags form control', () => { + setup(); + component.updateTags(['alpha', 'beta']); + expect(component.metadataForm.controls.tags.value).toEqual(['alpha', 'beta']); }); - it('should handle back button click with changes', () => { - component.metadataForm.patchValue({ - doi: 'new-doi', + it('should return early in backButtonClicked when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + detectChanges: false, }); + component.initForm(); + const backClickedSpy = jest.spyOn(component.backClicked, 'emit'); component.backButtonClicked(); - expect(customConfirmationServiceMock.confirmContinue).toHaveBeenCalled(); + expect(backClickedSpy).not.toHaveBeenCalled(); + expect(customConfirmationServiceMock.confirmContinue).not.toHaveBeenCalled(); }); - it('should handle select license without required fields', () => { - const license = mockLicenses[0]; + it('should emit back when there are no changes in backButtonClicked', () => { + setup(); + const backClickedSpy = jest.spyOn(component.backClicked, 'emit'); + component.metadataForm.patchValue({ subjects: [{ id: 'subject-1', name: 'Subject 1' }] }); - expect(() => component.selectLicense(license)).not.toThrow(); + component.backButtonClicked(); + + expect(backClickedSpy).toHaveBeenCalled(); + expect(customConfirmationServiceMock.confirmContinue).not.toHaveBeenCalled(); }); - it('should handle select license with required fields', () => { - const license = mockLicenses[0]; + it('should request confirmation and emit on confirm when there are changes in backButtonClicked', () => { + setup(); + const backClickedSpy = jest.spyOn(component.backClicked, 'emit'); + component.metadataForm.patchValue({ doi: '10.9999/changed', subjects: [{ id: 'subject-1', name: 'Subject 1' }] }); - expect(() => component.selectLicense(license)).not.toThrow(); + component.backButtonClicked(); + + expect(customConfirmationServiceMock.confirmContinue).toHaveBeenCalledWith({ + headerKey: 'common.discardChanges.header', + messageKey: 'common.discardChanges.message', + onConfirm: expect.any(Function), + onReject: expect.any(Function), + }); + + const { onConfirm } = customConfirmationServiceMock.confirmContinue.mock.calls[0][0]; + onConfirm(); + expect(backClickedSpy).toHaveBeenCalled(); }); - it('should handle edge case with empty licenses', () => { - const licenses = component.licenses(); - expect(licenses).toBeDefined(); - expect(Array.isArray(licenses)).toBe(true); + it('should not emit on reject when there are changes in backButtonClicked', () => { + setup(); + const backClickedSpy = jest.spyOn(component.backClicked, 'emit'); + component.metadataForm.patchValue({ doi: '10.9999/changed', subjects: [{ id: 'subject-1', name: 'Subject 1' }] }); + + component.backButtonClicked(); + + const { onReject } = customConfirmationServiceMock.confirmContinue.mock.calls[0][0]; + onReject(); + expect(backClickedSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.ts index 3d55f43ad..66be42590 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.ts @@ -15,7 +15,6 @@ import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angula import { formInputLimits } from '@osf/features/preprints/constants'; import { MetadataForm, PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/models'; import { - CreatePreprint, FetchLicenses, PreprintStepperSelectors, SaveLicense, @@ -39,21 +38,21 @@ import { PreprintsSubjectsComponent } from './preprints-subjects/preprints-subje @Component({ selector: 'osf-preprints-metadata', imports: [ - PreprintsContributorsComponent, Button, Card, - ReactiveFormsModule, Message, - TranslatePipe, DatePicker, - IconComponent, InputText, - TextInputComponent, Tooltip, + ReactiveFormsModule, + IconComponent, LicenseComponent, TagsInputComponent, PreprintsSubjectsComponent, + PreprintsContributorsComponent, PreprintsAffiliatedInstitutionsComponent, + TextInputComponent, + TranslatePipe, ], templateUrl: './preprints-metadata-step.component.html', styleUrl: './preprints-metadata-step.component.scss', @@ -62,27 +61,29 @@ import { PreprintsSubjectsComponent } from './preprints-subjects/preprints-subje export class PreprintsMetadataStepComponent implements OnInit { private customConfirmationService = inject(CustomConfirmationService); private toastService = inject(ToastService); + + provider = input.required(); + nextClicked = output(); + backClicked = output(); + private actions = createDispatchMap({ - createPreprint: CreatePreprint, updatePreprint: UpdatePreprint, fetchLicenses: FetchLicenses, saveLicense: SaveLicense, }); - metadataForm!: FormGroup; - inputLimits = formInputLimits; - readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - today = new Date(); - licenses = select(PreprintStepperSelectors.getLicenses); createdPreprint = select(PreprintStepperSelectors.getPreprint); isUpdatingPreprint = select(PreprintStepperSelectors.isPreprintSubmitting); - provider = input.required(); - nextClicked = output(); - backClicked = output(); + metadataForm!: FormGroup; + today = new Date(); + defaultLicense = signal(undefined); + readonly inputLimits = formInputLimits; + readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; + constructor() { effect(() => { const licenses = this.licenses(); @@ -101,7 +102,7 @@ export class PreprintsMetadataStepComponent implements OnInit { } ngOnInit() { - this.actions.fetchLicenses(); + this.actions.fetchLicenses(this.provider().id); this.initForm(); } @@ -136,11 +137,16 @@ export class PreprintsMetadataStepComponent implements OnInit { return; } - const model = this.metadataForm.value; + const preprint = this.createdPreprint(); - const changedFields = findChangedFields(model, this.createdPreprint()!); + if (!preprint) { + return; + } + + const model = this.metadataForm.value; + const changedFields = findChangedFields(model, preprint); - this.actions.updatePreprint(this.createdPreprint()!.id, changedFields).subscribe({ + this.actions.updatePreprint(preprint.id, changedFields).subscribe({ complete: () => { this.toastService.showSuccess('preprints.preprintStepper.common.successMessages.preprintSaved'); this.nextClicked.emit(); @@ -156,6 +162,7 @@ export class PreprintsMetadataStepComponent implements OnInit { if (license.requiredFields.length) { return; } + this.actions.saveLicense(license.id); } @@ -166,9 +173,14 @@ export class PreprintsMetadataStepComponent implements OnInit { } backButtonClicked() { - const formValue = this.metadataForm.value; - delete formValue.subjects; - const changedFields = findChangedFields(formValue, this.createdPreprint()!); + const preprint = this.createdPreprint(); + + if (!preprint) { + return; + } + + const { subjects: _subjects, ...formValue } = this.metadataForm.value; + const changedFields = findChangedFields(formValue, preprint); if (!Object.keys(changedFields).length) { this.backClicked.emit(); diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.spec.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.spec.ts index 1c9a4abe3..cf2f5c50a 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.spec.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.spec.ts @@ -1,167 +1,91 @@ +import { Store } from '@ngxs/store'; + import { MockComponent } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl } from '@angular/forms'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; import { SubjectsComponent } from '@osf/shared/components/subjects/subjects.component'; -import { SubjectModel } from '@osf/shared/models/subject/subject.model'; -import { SubjectsSelectors } from '@osf/shared/stores/subjects'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { + FetchChildrenSubjects, + FetchSelectedSubjects, + FetchSubjects, + SubjectsSelectors, + UpdateResourceSubjects, +} from '@osf/shared/stores/subjects'; import { PreprintsSubjectsComponent } from './preprints-subjects.component'; import { SUBJECTS_MOCK } from '@testing/mocks/subject.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; describe('PreprintsSubjectsComponent', () => { let component: PreprintsSubjectsComponent; let fixture: ComponentFixture; + let store: Store; - const mockSubjects: SubjectModel[] = SUBJECTS_MOCK; + const mockSubjects = SUBJECTS_MOCK; + const defaultSignals: SignalOverride[] = [ + { selector: SubjectsSelectors.getSelectedSubjects, value: mockSubjects }, + { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false }, + ]; - const mockFormControl = new FormControl([]); + function setup(overrides?: { preprintId?: string; providerId?: string; selectorOverrides?: SignalOverride[] }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + const control = new FormControl([]); - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PreprintsSubjectsComponent, OSFTestingModule, MockComponent(SubjectsComponent)], - providers: [ - provideMockStore({ - signals: [ - { selector: PreprintStepperSelectors.getSelectedProviderId, value: 'test-provider-id' }, - { selector: SubjectsSelectors.getSelectedSubjects, value: mockSubjects }, - { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false }, - ], - }), - ], - }).compileComponents(); + TestBed.configureTestingModule({ + imports: [PreprintsSubjectsComponent, MockComponent(SubjectsComponent)], + providers: [provideOSFCore(), provideMockStore({ signals })], + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintsSubjectsComponent); component = fixture.componentInstance; - - fixture.componentRef.setInput('preprintId', 'test-preprint-id'); - fixture.componentRef.setInput('control', mockFormControl); - + fixture.componentRef.setInput('control', control); + fixture.componentRef.setInput('providerId', overrides?.providerId ?? 'test-provider-id'); + fixture.componentRef.setInput( + 'preprintId', + overrides && 'preprintId' in overrides ? overrides.preprintId : 'test-preprint-id' + ); fixture.detectChanges(); - }); - - describe('Component Creation', () => { - it('should create', () => { - expect(component).toBeTruthy(); - }); + } - it('should be an instance of PreprintsSubjectsComponent', () => { - expect(component).toBeInstanceOf(PreprintsSubjectsComponent); - }); - }); + it('should fetch provider subjects and selected subjects on init when ids exist', () => { + setup(); - it('should have required inputs', () => { - expect(component.preprintId()).toBe('test-preprint-id'); - expect(component.control()).toBe(mockFormControl); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSubjects(ResourceType.Preprint, 'test-provider-id')); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSelectedSubjects('test-preprint-id', ResourceType.Preprint)); + expect(component.control().value).toEqual(mockSubjects); }); - it('should have NGXS selectors defined', () => { - expect(component.selectedSubjects).toBeDefined(); - expect(component.isSubjectsUpdating).toBeDefined(); - expect(component['selectedProviderId']).toBeDefined(); - }); + it('should dispatch child subjects fetch', () => { + setup(); - it('should have actions defined', () => { - expect(component.actions).toBeDefined(); - expect(component.actions.fetchSubjects).toBeDefined(); - expect(component.actions.fetchSelectedSubjects).toBeDefined(); - expect(component.actions.fetchChildrenSubjects).toBeDefined(); - expect(component.actions.updateResourceSubjects).toBeDefined(); - }); + component.getSubjectChildren('parent-123'); - it('should have INPUT_VALIDATION_MESSAGES constant', () => { - expect(component.INPUT_VALIDATION_MESSAGES).toBeDefined(); + expect(store.dispatch).toHaveBeenCalledWith(new FetchChildrenSubjects('parent-123')); }); - it('should get selected subjects from store', () => { - expect(component.selectedSubjects()).toEqual(mockSubjects); + it('should search subjects', () => { + setup(); + component.searchSubjects('math'); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSubjects(ResourceType.Preprint, 'test-provider-id', 'math')); }); - it('should get subjects loading state from store', () => { - expect(component.isSubjectsUpdating()).toBe(false); - }); - - it('should get selected provider ID from store', () => { - expect(component['selectedProviderId']()).toBe('test-provider-id'); - }); - - it('should call getSubjectChildren with parent ID', () => { - const parentId = 'parent-123'; - - expect(() => component.getSubjectChildren(parentId)).not.toThrow(); - }); - - it('should call searchSubjects with search term', () => { - const searchTerm = 'mathematics'; - - expect(() => component.searchSubjects(searchTerm)).not.toThrow(); - }); - - it('should handle null control gracefully', () => { - const nullControl = new FormControl(null); - fixture.componentRef.setInput('control', nullControl); - - expect(() => component.updateControlState(mockSubjects)).not.toThrow(); - }); + it('should update control state and resource subjects when preprint id exists', () => { + setup(); + const control = component.control(); - it('should mark control as touched and dirty', () => { - const freshControl = new FormControl([]); - fixture.componentRef.setInput('control', freshControl); - - component.updateControlState(mockSubjects); - - expect(freshControl.touched).toBe(true); - expect(freshControl.dirty).toBe(true); - }); - - it('should render subjects component', () => { - const subjectsComponent = fixture.nativeElement.querySelector('osf-subjects'); - expect(subjectsComponent).toBeTruthy(); - }); - - it('should handle control with required error', () => { - mockFormControl.setErrors({ required: true }); - mockFormControl.markAsTouched(); - mockFormControl.markAsDirty(); - fixture.detectChanges(); - - expect(component).toBeTruthy(); - expect(mockFormControl.errors).toEqual({ required: true }); - }); - - it('should not show error message when control is valid', () => { - mockFormControl.setErrors(null); - fixture.detectChanges(); - - const errorMessage = fixture.nativeElement.querySelector('p-message'); - expect(errorMessage).toBeFalsy(); - }); - - it('should handle empty preprintId', () => { - fixture.componentRef.setInput('preprintId', ''); - - expect(() => component.ngOnInit()).not.toThrow(); - }); - - it('should handle undefined preprintId', () => { - fixture.componentRef.setInput('preprintId', undefined); - - expect(() => component.ngOnInit()).not.toThrow(); - }); - - it('should handle empty subjects array', () => { - const emptySubjects: SubjectModel[] = []; - - expect(() => component.updateSelectedSubjects(emptySubjects)).not.toThrow(); - expect(mockFormControl.value).toEqual(emptySubjects); - }); + component.updateSelectedSubjects(mockSubjects); - it('should handle null subjects', () => { - expect(() => component.updateControlState(null as any)).not.toThrow(); + expect(control.value).toEqual(mockSubjects); + expect(control.touched).toBe(true); + expect(control.dirty).toBe(true); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateResourceSubjects('test-preprint-id', ResourceType.Preprint, mockSubjects) + ); }); }); diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.ts index 0d18ceb5c..cad361ca3 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.ts @@ -8,7 +8,6 @@ import { Message } from 'primeng/message'; import { ChangeDetectionStrategy, Component, effect, input, OnInit } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; import { SubjectsComponent } from '@osf/shared/components/subjects/subjects.component'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; @@ -29,21 +28,22 @@ import { SubjectModel } from '@shared/models/subject/subject.model'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintsSubjectsComponent implements OnInit { - preprintId = input(); + readonly control = input.required(); + readonly providerId = input.required(); + readonly preprintId = input.required(); - private readonly selectedProviderId = select(PreprintStepperSelectors.getSelectedProviderId); - selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); - isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); - control = input.required(); + readonly selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); + readonly isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); - readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - actions = createDispatchMap({ + readonly actions = createDispatchMap({ fetchSubjects: FetchSubjects, fetchSelectedSubjects: FetchSelectedSubjects, fetchChildrenSubjects: FetchChildrenSubjects, updateResourceSubjects: UpdateResourceSubjects, }); + readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; + constructor() { effect(() => { this.updateControlState(this.selectedSubjects()); @@ -51,30 +51,28 @@ export class PreprintsSubjectsComponent implements OnInit { } ngOnInit(): void { - this.actions.fetchSubjects(ResourceType.Preprint, this.selectedProviderId()!); + this.actions.fetchSubjects(ResourceType.Preprint, this.providerId()); this.actions.fetchSelectedSubjects(this.preprintId()!, ResourceType.Preprint); } - getSubjectChildren(parentId: string) { + getSubjectChildren(parentId: string): void { this.actions.fetchChildrenSubjects(parentId); } - searchSubjects(search: string) { - this.actions.fetchSubjects(ResourceType.Preprint, this.selectedProviderId()!, search); + searchSubjects(search: string): void { + this.actions.fetchSubjects(ResourceType.Preprint, this.providerId(), search); } - updateSelectedSubjects(subjects: SubjectModel[]) { + updateSelectedSubjects(subjects: SubjectModel[]): void { this.updateControlState(subjects); - - this.actions.updateResourceSubjects(this.preprintId()!, ResourceType.Preprint, subjects); + this.actions.updateResourceSubjects(this.preprintId(), ResourceType.Preprint, subjects); } - updateControlState(value: SubjectModel[]) { - if (this.control()) { - this.control().setValue(value); - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); - } + updateControlState(value: SubjectModel[]): void { + const control = this.control(); + control.setValue(value); + control.markAsTouched(); + control.markAsDirty(); + control.updateValueAndValidity(); } } diff --git a/src/app/features/preprints/components/stepper/review-step/review-step.component.html b/src/app/features/preprints/components/stepper/review-step/review-step.component.html index c3d45f941..236dc3d20 100644 --- a/src/app/features/preprints/components/stepper/review-step/review-step.component.html +++ b/src/app/features/preprints/components/stepper/review-step/review-step.component.html @@ -5,7 +5,7 @@

{{ 'preprints.preprintStepper.review.title' | translate | titlecase }}

{{ 'preprints.preprintStepper.review.workflowDescription' - | translate: { providerName: provider()?.name, reviewsWorkflow: provider()?.reviewsWorkflow } + | translate: { providerName: provider().name, reviewsWorkflow: provider().reviewsWorkflow } }}

@@ -28,30 +28,32 @@

{{ 'preprints.preprintStepper.review.sections.titleAndAbstract.title' | tran

{{ 'preprints.preprintStepper.review.sections.titleAndAbstract.service' - | translate: { preprintWord: provider()?.preprintWord | titlecase } + | translate: { preprintWord: provider().preprintWord | titlecase } }}

Provider logo -

{{ provider()?.name }}

+

{{ provider().name }}

-
-

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

-

{{ preprint()!.title }}

-
+ @if (preprint(); as preprint) { +
+

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

+

{{ preprint.title }}

+
-
-

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

- -
+
+

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

+ +
+ } @@ -143,7 +145,7 @@

{{ 'preprints.preprintStepper.review.sections.metadata.publicationCitation' -@if (provider()?.assertionsEnabled) { +@if (provider().assertionsEnabled) {

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

@@ -222,7 +224,7 @@

{{ 'preprints.preprintStepper.review.sections.supplements.title' | translate }}

@if (preprintProject()) { -

{{ preprintProject()?.name | fixSpecialChar }}

+

{{ preprintProject()?.name }}

} @else {

{{ 'preprints.preprintStepper.review.sections.supplements.noSupplements' | translate }}

} @@ -235,15 +237,15 @@

{{ 'preprints.preprintStepper.review.sections.supplements.title' | translate styleClass="w-full" [label]="'common.buttons.cancel' | translate" severity="info" - (click)="cancelSubmission()" [disabled]="isPreprintSubmitting()" + (onClick)="cancelSubmission()" /> diff --git a/src/app/features/preprints/components/stepper/review-step/review-step.component.spec.ts b/src/app/features/preprints/components/stepper/review-step/review-step.component.spec.ts index 3187a6bdb..730374545 100644 --- a/src/app/features/preprints/components/stepper/review-step/review-step.component.spec.ts +++ b/src/app/features/preprints/components/stepper/review-step/review-step.component.spec.ts @@ -1,18 +1,34 @@ -import { MockComponents, MockPipe } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponents, MockPipe, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { PreprintProviderDetails } from '@osf/features/preprints/models'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; +import { + FetchLicenses, + FetchPreprintProject, + PreprintStepperSelectors, + SubmitPreprint, + UpdatePreprint, + UpdatePrimaryFileRelationship, +} from '@osf/features/preprints/store/preprint-stepper'; import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { InterpolatePipe } from '@osf/shared/pipes/interpolate.pipe'; import { ToastService } from '@osf/shared/services/toast.service'; -import { InterpolatePipe } from '@shared/pipes/interpolate.pipe'; -import { ContributorsSelectors } from '@shared/stores/contributors'; -import { InstitutionsSelectors } from '@shared/stores/institutions'; -import { SubjectsSelectors } from '@shared/stores/subjects'; +import { + ContributorsSelectors, + GetBibliographicContributors, + LoadMoreBibliographicContributors, +} from '@osf/shared/stores/contributors'; +import { FetchResourceInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions'; +import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; + +import { ReviewsState } from '../../../enums'; import { ReviewStepComponent } from './review-step.component'; @@ -23,83 +39,214 @@ import { OSF_FILE_MOCK } from '@testing/mocks/osf-file.mock'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; 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 { RouterMock } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { RouterMock, 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('ReviewStepComponent', () => { let component: ReviewStepComponent; let fixture: ComponentFixture; - let router: jest.Mocked; + let store: Store; + let routerMock: RouterMockType; + let toastMock: ToastServiceMockType; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; const mockPreprint = PREPRINT_MOCK; const mockPreprintFile = OSF_FILE_MOCK; - const mockContributors = [MOCK_CONTRIBUTOR]; - const mockSubjects = SUBJECTS_MOCK; - const mockInstitutions = [MOCK_INSTITUTION]; - const mockLicense = MOCK_LICENSE; - const mockPreprintProject = { - id: 'project-id', - name: 'Test Project', - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ + + const defaultSignals: SignalOverride[] = [ + { selector: PreprintStepperSelectors.getPreprint, value: mockPreprint }, + { selector: PreprintStepperSelectors.getPreprintFile, value: mockPreprintFile }, + { selector: PreprintStepperSelectors.isPreprintSubmitting, value: false }, + { selector: PreprintStepperSelectors.getPreprintLicense, value: MOCK_LICENSE }, + { selector: PreprintStepperSelectors.getPreprintProject, value: { id: 'project-id', name: 'Test Project' } }, + { selector: ContributorsSelectors.getBibliographicContributors, value: [MOCK_CONTRIBUTOR] }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, + { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false }, + { selector: SubjectsSelectors.getSelectedSubjects, value: SUBJECTS_MOCK }, + { selector: InstitutionsSelectors.getResourceInstitutions, value: [MOCK_INSTITUTION] }, + ]; + + function setup(overrides?: { + selectorOverrides?: SignalOverride[]; + provider?: PreprintProviderDetails; + detectChanges?: boolean; + }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + routerMock = RouterMock.create().build(); + toastMock = ToastServiceMock.simple(); + + TestBed.configureTestingModule({ imports: [ ReviewStepComponent, - OSFTestingModule, ...MockComponents(AffiliatedInstitutionsViewComponent, ContributorsListComponent, LicenseDisplayComponent), MockPipe(InterpolatePipe), ], providers: [ - { provide: Router, useValue: RouterMock.create().build() }, - { provide: ToastService, useValue: ToastServiceMock.simple() }, - provideMockStore({ - signals: [ - { selector: PreprintStepperSelectors.getPreprint, value: mockPreprint }, - { selector: PreprintStepperSelectors.getPreprintFile, value: mockPreprintFile }, - { selector: PreprintStepperSelectors.isPreprintSubmitting, value: false }, - { selector: PreprintStepperSelectors.getPreprintLicense, value: mockLicense }, - { selector: PreprintStepperSelectors.getPreprintProject, value: mockPreprintProject }, - { selector: ContributorsSelectors.getBibliographicContributors, value: mockContributors }, - { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, - { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false }, - { selector: SubjectsSelectors.getSelectedSubjects, value: mockSubjects }, - { selector: InstitutionsSelectors.getResourceInstitutions, value: mockInstitutions }, - ], - }), + provideOSFCore(), + MockProvider(Router, routerMock), + MockProvider(ToastService, toastMock), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(ReviewStepComponent); component = fixture.componentInstance; - router = TestBed.inject(Router) as jest.Mocked; + fixture.componentRef.setInput('provider', overrides && 'provider' in overrides ? overrides.provider : mockProvider); + if (overrides?.detectChanges ?? true) { + fixture.detectChanges(); + } + } - fixture.componentRef.setInput('provider', mockProvider); + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); }); - it('should have required provider input', () => { - expect(component.provider()).toEqual(mockProvider); + it('should dispatch initial fetch actions when preprint exists', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses(mockProvider.id)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintProject()); + expect(store.dispatch).toHaveBeenCalledWith( + new GetBibliographicContributors(mockPreprint.id, ResourceType.Preprint) + ); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSelectedSubjects(mockPreprint.id, ResourceType.Preprint)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchResourceInstitutions(mockPreprint.id, ResourceType.Preprint)); }); - it('should create license options record', () => { - const licenseOptionsRecord = component.licenseOptionsRecord(); - expect(licenseOptionsRecord).toEqual({ copyrightHolders: 'John Doe', year: '2023' }); + it('should skip preprint-dependent fetches when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + }); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses(mockProvider.id)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintProject()); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetBibliographicContributors)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchSelectedSubjects)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchResourceInstitutions)); }); - it('should handle cancelSubmission method', () => { + it('should expose license options record from preprint', () => { + setup(); + expect(component.licenseOptionsRecord()).toEqual(mockPreprint.licenseOptions ?? {}); + }); + + it('should expose empty license options record when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + detectChanges: false, + }); + expect(component.licenseOptionsRecord()).toEqual({}); + }); + + it('should navigate to preprints list on cancel', () => { + setup(); + component.cancelSubmission(); - expect(router.navigateByUrl).toHaveBeenCalledWith('/preprints'); + + expect(routerMock.navigateByUrl).toHaveBeenCalledWith('/preprints'); + }); + + it('should return early in submitPreprint when required data is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + detectChanges: false, + }); + + component.submitPreprint(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdatePrimaryFileRelationship)); + expect(toastMock.showSuccess).not.toHaveBeenCalled(); + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + + it('should return early in submitPreprint when no file id is available', () => { + const preprintWithoutPrimaryFileId = { ...mockPreprint, primaryFileId: null }; + setup({ + selectorOverrides: [ + { selector: PreprintStepperSelectors.getPreprint, value: preprintWithoutPrimaryFileId }, + { selector: PreprintStepperSelectors.getPreprintFile, value: null }, + ], + }); + + component.submitPreprint(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdatePrimaryFileRelationship)); + expect(toastMock.showSuccess).not.toHaveBeenCalled(); + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + + it('should publish directly when provider has no reviews workflow', () => { + setup({ + provider: { ...mockProvider, reviewsWorkflow: null }, + }); + + component.submitPreprint(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdatePrimaryFileRelationship(mockPreprintFile.id)); + expect(store.dispatch).toHaveBeenCalledWith(new UpdatePreprint(mockPreprint.id, { isPublished: true })); + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.common.successMessages.preprintSubmitted' + ); + expect(routerMock.navigate).toHaveBeenCalledWith(['/preprints', mockProvider.id, mockPreprint.id]); }); - it('should handle submitting state', () => { - expect(component.isPreprintSubmitting()).toBe(false); + it('should submit preprint when workflow exists and reviews state is not accepted', () => { + const preprintPending = { ...mockPreprint, reviewsState: ReviewsState.Pending }; + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: preprintPending }], + }); + + component.submitPreprint(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdatePrimaryFileRelationship(mockPreprintFile.id)); + expect(store.dispatch).toHaveBeenCalledWith(new SubmitPreprint()); + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.common.successMessages.preprintSubmitted' + ); + expect(routerMock.navigate).toHaveBeenCalledWith(['/preprints', mockProvider.id, mockPreprint.id]); }); - it('should have proper method signatures', () => { - expect(typeof component.submitPreprint).toBe('function'); - expect(typeof component.cancelSubmission).toBe('function'); + it('should skip submit/update when reviews state is accepted and still navigate', () => { + const preprintAccepted = { ...mockPreprint, reviewsState: ReviewsState.Accepted }; + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: preprintAccepted }], + }); + + component.submitPreprint(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdatePrimaryFileRelationship(mockPreprintFile.id)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SubmitPreprint)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdatePreprint)); + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.common.successMessages.preprintSubmitted' + ); + expect(routerMock.navigate).toHaveBeenCalledWith(['/preprints', mockProvider.id, mockPreprint.id]); + }); + + it('should load more contributors when preprint id exists', () => { + setup(); + + component.loadMoreContributors(); + + expect(store.dispatch).toHaveBeenCalledWith( + new LoadMoreBibliographicContributors(mockPreprint.id, ResourceType.Preprint) + ); + }); + + it('should load more contributors with undefined id when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + detectChanges: false, + }); + + component.loadMoreContributors(); + + expect(store.dispatch).toHaveBeenCalledWith( + new LoadMoreBibliographicContributors(undefined, ResourceType.Preprint) + ); }); }); diff --git a/src/app/features/preprints/components/stepper/review-step/review-step.component.ts b/src/app/features/preprints/components/stepper/review-step/review-step.component.ts index 99a0375d4..5a37192fb 100644 --- a/src/app/features/preprints/components/stepper/review-step/review-step.component.ts +++ b/src/app/features/preprints/components/stepper/review-step/review-step.component.ts @@ -26,7 +26,6 @@ import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affi import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; -import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe'; import { ToastService } from '@osf/shared/services/toast.service'; import { ResourceType } from '@shared/enums/resource-type.enum'; import { @@ -40,26 +39,28 @@ import { FetchSelectedSubjects, SubjectsSelectors } from '@shared/stores/subject @Component({ selector: 'osf-review-step', imports: [ + Button, Card, - TruncatedTextComponent, Tag, - DatePipe, - Button, - TitleCasePipe, - TranslatePipe, AffiliatedInstitutionsViewComponent, ContributorsListComponent, LicenseDisplayComponent, - FixSpecialCharPipe, + TruncatedTextComponent, + DatePipe, + TitleCasePipe, + TranslatePipe, ], templateUrl: './review-step.component.html', styleUrl: './review-step.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ReviewStepComponent implements OnInit { - private router = inject(Router); - private toastService = inject(ToastService); - private actions = createDispatchMap({ + private readonly router = inject(Router); + private readonly toastService = inject(ToastService); + + readonly provider = input.required(); + + private readonly actions = createDispatchMap({ getBibliographicContributors: GetBibliographicContributors, fetchSubjects: FetchSelectedSubjects, fetchLicenses: FetchLicenses, @@ -71,41 +72,55 @@ export class ReviewStepComponent implements OnInit { loadMoreBibliographicContributors: LoadMoreBibliographicContributors, }); - provider = input.required(); + readonly preprint = select(PreprintStepperSelectors.getPreprint); + readonly preprintFile = select(PreprintStepperSelectors.getPreprintFile); + readonly isPreprintSubmitting = select(PreprintStepperSelectors.isPreprintSubmitting); - preprint = select(PreprintStepperSelectors.getPreprint); - preprintFile = select(PreprintStepperSelectors.getPreprintFile); - isPreprintSubmitting = select(PreprintStepperSelectors.isPreprintSubmitting); - - bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); - areContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); - hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); - subjects = select(SubjectsSelectors.getSelectedSubjects); - affiliatedInstitutions = select(InstitutionsSelectors.getResourceInstitutions); - license = select(PreprintStepperSelectors.getPreprintLicense); - preprintProject = select(PreprintStepperSelectors.getPreprintProject); - licenseOptionsRecord = computed(() => (this.preprint()?.licenseOptions ?? {}) as Record); + readonly bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); + readonly areContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); + readonly hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); + readonly subjects = select(SubjectsSelectors.getSelectedSubjects); + readonly affiliatedInstitutions = select(InstitutionsSelectors.getResourceInstitutions); + readonly license = select(PreprintStepperSelectors.getPreprintLicense); + readonly preprintProject = select(PreprintStepperSelectors.getPreprintProject); + readonly licenseOptionsRecord = computed(() => (this.preprint()?.licenseOptions ?? {}) as Record); readonly ApplicabilityStatus = ApplicabilityStatus; readonly PreregLinkInfo = PreregLinkInfo; ngOnInit(): void { - this.actions.getBibliographicContributors(this.preprint()?.id, ResourceType.Preprint); - this.actions.fetchSubjects(this.preprint()!.id, ResourceType.Preprint); - this.actions.fetchLicenses(); + this.actions.fetchLicenses(this.provider().id); this.actions.fetchPreprintProject(); - this.actions.fetchResourceInstitutions(this.preprint()!.id, ResourceType.Preprint); + + const preprintId = this.preprint()?.id; + if (!preprintId) { + return; + } + + this.actions.getBibliographicContributors(preprintId, ResourceType.Preprint); + this.actions.fetchSubjects(preprintId, ResourceType.Preprint); + this.actions.fetchResourceInstitutions(preprintId, ResourceType.Preprint); } - submitPreprint() { - const preprint = this.preprint()!; - const preprintFile = this.preprintFile()!; + submitPreprint(): void { + const preprint = this.preprint(); + const provider = this.provider(); + + if (!preprint) { + return; + } + + const preprintFileId = this.preprintFile()?.id ?? preprint.primaryFileId; + + if (!preprintFileId) { + return; + } this.actions - .updatePrimaryFileRelationship(preprintFile?.id ?? preprint.primaryFileId) + .updatePrimaryFileRelationship(preprintFileId) .pipe( switchMap(() => { - if (!this.provider()?.reviewsWorkflow) { + if (!provider.reviewsWorkflow) { return this.actions.updatePreprint(preprint.id, { isPublished: true }); } @@ -116,13 +131,13 @@ export class ReviewStepComponent implements OnInit { }), tap(() => { this.toastService.showSuccess('preprints.preprintStepper.common.successMessages.preprintSubmitted'); - this.router.navigate(['/preprints', this.provider()!.id, preprint.id]); + this.router.navigate(['/preprints', provider.id, preprint.id]); }) ) .subscribe(); } - cancelSubmission() { + cancelSubmission(): void { this.router.navigateByUrl('/preprints'); } diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html index fb3d8b8c9..f90168727 100644 --- a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html +++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html @@ -15,7 +15,7 @@

{{ 'preprints.preprintStepper.supplements.title' | translate }}

styleClass="w-full" [label]="'preprints.preprintStepper.supplements.options.connectExisting' | translate" severity="secondary" - (click)="selectSupplementOption(SupplementOptions.ConnectExistingProject)" + (onClick)="selectSupplementOption(SupplementOptions.ConnectExistingProject)" /> @@ -33,26 +33,26 @@

{{ 'preprints.preprintStepper.supplements.title' | translate }}

- {{ 'preprints.preprintStepper.supplements.projectSelection.description' | translate }} + {{ 'preprints.preprintStepper.projectSelection.description' | translate }}

- {{ 'preprints.preprintStepper.supplements.projectSelection.subDescription' | translate }} + {{ 'preprints.preprintStepper.projectSelection.subDescription' | translate }}

@@ -88,7 +88,7 @@

{{ 'preprints.preprintStepper.supplements.title' | translate }}

styleClass="w-full" [label]="'common.buttons.back' | translate" severity="info" - (click)="backButtonClicked()" + (onClick)="backButtonClicked()" /> {{ 'preprints.preprintStepper.supplements.title' | translate }}

[label]="'common.buttons.next' | translate" [disabled]="isNextButtonDisabled()" [loading]="isPreprintSubmitting()" - (click)="nextButtonClicked()" + (onClick)="nextButtonClicked()" /> diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts index 8f7123c13..da3b8489e 100644 --- a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts +++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts @@ -1,125 +1,349 @@ -import { MockComponent, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { ConfirmationService } from 'primeng/api'; +import { MockComponent, MockProvider } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; +import { SupplementOptions } from '@osf/features/preprints/enums'; +import { + ConnectProject, + CreateNewProject, + DisconnectProject, + FetchAvailableProjects, + FetchPreprintProject, + PreprintStepperSelectors, +} from '@osf/features/preprints/store/preprint-stepper'; import { AddProjectFormComponent } from '@osf/shared/components/add-project-form/add-project-form.component'; +import { ProjectFormControls } from '@osf/shared/enums/create-project-form-controls.enum'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { SupplementsStepComponent } from './supplements-step.component'; -import { TranslateServiceMock } from '@testing/mocks/translate.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; +import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('SupplementsStepComponent', () => { let component: SupplementsStepComponent; let fixture: ComponentFixture; - let mockToastService: ReturnType; + let store: Store; + let toastMock: ToastServiceMockType; + let confirmationMock: CustomConfirmationServiceMockType; + const originalPointerEvent = (globalThis as unknown as { PointerEvent?: typeof Event }).PointerEvent; + + const defaultSignals: SignalOverride[] = [ + { selector: PreprintStepperSelectors.getPreprint, value: { ...PREPRINT_MOCK, nodeId: null } }, + { selector: PreprintStepperSelectors.isPreprintSubmitting, value: false }, + { selector: PreprintStepperSelectors.getAvailableProjects, value: [] }, + { selector: PreprintStepperSelectors.areAvailableProjectsLoading, value: false }, + { selector: PreprintStepperSelectors.getPreprintProject, value: null }, + { selector: PreprintStepperSelectors.isPreprintProjectLoading, value: false }, + ]; - beforeEach(async () => { - mockToastService = ToastServiceMock.simple(); + function setup(overrides?: { selectorOverrides?: SignalOverride[]; detectChanges?: boolean }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + toastMock = ToastServiceMock.simple(); + confirmationMock = CustomConfirmationServiceMock.simple(); - await TestBed.configureTestingModule({ - imports: [SupplementsStepComponent, MockComponent(AddProjectFormComponent), OSFTestingModule], + TestBed.configureTestingModule({ + imports: [SupplementsStepComponent, MockComponent(AddProjectFormComponent)], providers: [ - provideMockStore({ - signals: [ - { - selector: PreprintStepperSelectors.getPreprint, - value: {}, - }, - { - selector: PreprintStepperSelectors.isPreprintSubmitting, - value: false, - }, - { - selector: PreprintStepperSelectors.getAvailableProjects, - value: [], - }, - { - selector: PreprintStepperSelectors.areAvailableProjectsLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.getPreprintProject, - value: null, - }, - { - selector: PreprintStepperSelectors.isPreprintProjectLoading, - value: false, - }, - ], - }), - TranslateServiceMock, - MockProvider(ConfirmationService, { - confirm: jest.fn(), - close: jest.fn(), - }), - { provide: ToastService, useValue: mockToastService }, + provideOSFCore(), + MockProvider(ToastService, toastMock), + MockProvider(CustomConfirmationService, confirmationMock), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(SupplementsStepComponent); component = fixture.componentInstance; - fixture.detectChanges(); + if (overrides?.detectChanges ?? true) { + fixture.detectChanges(); + } + } + + beforeAll(() => { + if (!(globalThis as unknown as { PointerEvent?: typeof Event }).PointerEvent) { + (globalThis as unknown as { PointerEvent: typeof Event }).PointerEvent = MouseEvent as unknown as typeof Event; + } + }); + + afterEach(() => { + fixture?.destroy(); + jest.restoreAllMocks(); + }); + + afterAll(() => { + if (originalPointerEvent) { + (globalThis as unknown as { PointerEvent: typeof Event }).PointerEvent = originalPointerEvent; + } else { + delete (globalThis as unknown as { PointerEvent?: typeof Event }).PointerEvent; + } }); it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should call getAvailableProjects when project name changes after debounce', () => { - jest.useFakeTimers(); + it('should fetch preprint project in constructor effect when node id exists and differs from selected project', () => { + setup({ + selectorOverrides: [ + { selector: PreprintStepperSelectors.getPreprint, value: { ...PREPRINT_MOCK, nodeId: 'node-1' } }, + { selector: PreprintStepperSelectors.getPreprintProject, value: { id: 'project-1', name: 'Test Project' } }, + ], + }); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintProject()); + }); - const getAvailableProjectsSpy = jest.fn(); - Object.defineProperty(component, 'actions', { - value: { getAvailableProjects: getAvailableProjectsSpy }, - writable: true, + it('should skip preprint project fetch when node id matches selected project id', () => { + setup({ + selectorOverrides: [ + { selector: PreprintStepperSelectors.getPreprint, value: { ...PREPRINT_MOCK, nodeId: 'node-1' } }, + { selector: PreprintStepperSelectors.getPreprintProject, value: { id: 'node-1', name: 'Node Project' } }, + ], }); - component.ngOnInit(); - component.projectNameControl.setValue('test-project'); - jest.advanceTimersByTime(500); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchPreprintProject)); + }); + + it('should skip preprint project fetch when node id is absent', () => { + setup({ + selectorOverrides: [ + { selector: PreprintStepperSelectors.getPreprint, value: { ...PREPRINT_MOCK, nodeId: null } }, + ], + }); - expect(getAvailableProjectsSpy).toHaveBeenCalledWith('test-project'); - jest.useRealTimers(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchPreprintProject)); }); - it('should not call getAvailableProjects if value is the same as selectedProjectId', () => { - jest.useFakeTimers(); - const getAvailableProjectsSpy = jest.fn(); + it('should dispatch available projects from debounced project search', fakeAsync(() => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + + component.projectNameControl.setValue('search-query'); + tick(500); + + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(store.dispatch).toHaveBeenCalledWith(new FetchAvailableProjects('search-query')); + })); - Object.defineProperty(component, 'actions', { - value: { getAvailableProjects: getAvailableProjectsSpy }, - writable: true, + it('should not dispatch before the debounce window elapses', fakeAsync(() => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + + component.projectNameControl.setValue('search-query'); + tick(300); + + expect(store.dispatch).not.toHaveBeenCalledWith(new FetchAvailableProjects('search-query')); + tick(200); + })); + + it('should skip available projects dispatch when value equals selected project id', fakeAsync(() => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + component.selectedProjectId.set('project-1'); + + component.projectNameControl.setValue('project-1'); + tick(500); + + expect(store.dispatch).not.toHaveBeenCalledWith(new FetchAvailableProjects('project-1')); + })); + + it('should select supplement option and reset create form for create-new option', () => { + setup({ detectChanges: false }); + component.createProjectForm.patchValue({ + [ProjectFormControls.Title]: 'Project', + [ProjectFormControls.StorageLocation]: 'region-1', }); - jest.spyOn(component, 'selectedProjectId').mockReturnValue('test-project'); - component.ngOnInit(); - component.projectNameControl.setValue('test-project'); - jest.advanceTimersByTime(500); + component.selectSupplementOption(SupplementOptions.CreateNewProject); + + expect(component.selectedSupplementOption()).toBe(SupplementOptions.CreateNewProject); + expect(component.createProjectForm.controls.title.value).toBe(''); + expect(component.createProjectForm.controls.storageLocation.value).toBe(''); + expect(store.dispatch).toHaveBeenCalledWith(new FetchAvailableProjects(null)); + }); + + it('should select supplement option without resetting form for connect-existing option', () => { + setup({ detectChanges: false }); + component.createProjectForm.patchValue({ [ProjectFormControls.Title]: 'Keep me' }); + + component.selectSupplementOption(SupplementOptions.ConnectExistingProject); - expect(getAvailableProjectsSpy).not.toHaveBeenCalled(); - jest.useRealTimers(); + expect(component.selectedSupplementOption()).toBe(SupplementOptions.ConnectExistingProject); + expect(component.createProjectForm.controls.title.value).toBe('Keep me'); }); - it('should handle empty values', () => { - jest.useFakeTimers(); - const getAvailableProjectsSpy = jest.fn(); - Object.defineProperty(component, 'actions', { - value: { getAvailableProjects: getAvailableProjectsSpy }, - writable: true, + it('should dispatch connect project and show success toast on project selection', () => { + setup({ detectChanges: false }); + + component.selectProject({ + value: 'project-1', + originalEvent: new PointerEvent('click'), + } as never); + + expect(component.selectedProjectId()).toBe('project-1'); + expect(store.dispatch).toHaveBeenCalledWith(new ConnectProject('project-1')); + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.supplements.successMessages.projectConnected' + ); + }); + + it('should return early in selectProject when event is not pointer event', () => { + setup({ detectChanges: false }); + + component.selectProject({ + value: 'project-1', + originalEvent: new Event('change'), + } as never); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(ConnectProject)); + expect(toastMock.showSuccess).not.toHaveBeenCalled(); + }); + + it('should disconnect project on confirmation and show success toast', () => { + setup({ detectChanges: false }); + component.selectedProjectId.set('project-1'); + + component.disconnectProject(); + + expect(confirmationMock.confirmDelete).toHaveBeenCalledWith({ + headerKey: 'preprints.preprintStepper.supplements.disconnectProject.header', + messageKey: 'preprints.preprintStepper.supplements.disconnectProject.message', + onConfirm: expect.any(Function), + }); + + const { onConfirm } = confirmationMock.confirmDelete.mock.calls[0][0]; + onConfirm(); + + expect(store.dispatch).toHaveBeenCalledWith(new DisconnectProject()); + expect(component.selectedProjectId()).toBeNull(); + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.supplements.successMessages.projectDisconnected' + ); + }); + + it('should not dispatch disconnect or clear selection when disconnect is cancelled', () => { + setup({ detectChanges: false }); + component.selectedProjectId.set('project-1'); + + component.disconnectProject(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(DisconnectProject)); + expect(component.selectedProjectId()).toBe('project-1'); + expect(toastMock.showSuccess).not.toHaveBeenCalled(); + }); + + it('should return early when create project form is invalid', () => { + setup({ detectChanges: false }); + const emitSpy = jest.spyOn(component.nextClicked, 'emit'); + + component.submitCreateProjectForm(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(CreateNewProject)); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should create project, show success and emit next when form is valid', () => { + setup({ detectChanges: false }); + const emitSpy = jest.spyOn(component.nextClicked, 'emit'); + component.createProjectForm.patchValue({ + [ProjectFormControls.Title]: 'New Project', + [ProjectFormControls.StorageLocation]: 'region-1', + [ProjectFormControls.Affiliations]: ['inst-1'], + [ProjectFormControls.Description]: 'Description', + [ProjectFormControls.Template]: 'template-id', + }); + + component.submitCreateProjectForm(); + + expect(store.dispatch).toHaveBeenCalledWith( + new CreateNewProject('New Project', 'Description', 'template-id', 'region-1', ['inst-1']) + ); + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.supplements.successMessages.projectCreated' + ); + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should submit create project form path in nextButtonClicked for create-new option', () => { + setup({ detectChanges: false }); + const createSpy = jest.spyOn(component, 'submitCreateProjectForm'); + component.selectedSupplementOption.set(SupplementOptions.CreateNewProject); + + component.nextButtonClicked(); + + expect(createSpy).toHaveBeenCalled(); + }); + + it('should emit next and show saved toast in nextButtonClicked for non-create option', () => { + setup({ detectChanges: false }); + const emitSpy = jest.spyOn(component.nextClicked, 'emit'); + component.selectedSupplementOption.set(SupplementOptions.ConnectExistingProject); + + component.nextButtonClicked(); + + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.common.successMessages.preprintSaved' + ); + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should handle discard-changes confirmation callbacks in backButtonClicked', () => { + setup({ detectChanges: false }); + const emitSpy = jest.spyOn(component.backClicked, 'emit'); + component.selectedSupplementOption.set(SupplementOptions.CreateNewProject); + component.createProjectForm.patchValue({ [ProjectFormControls.Title]: 'Has data' }); + + component.backButtonClicked(); + + expect(confirmationMock.confirmContinue).toHaveBeenCalledWith({ + headerKey: 'preprints.preprintStepper.supplements.discardChanges.header', + messageKey: 'preprints.preprintStepper.supplements.discardChanges.message', + onConfirm: expect.any(Function), + onReject: expect.any(Function), }); - component.ngOnInit(); - component.projectNameControl.setValue(''); - jest.advanceTimersByTime(500); + const { onReject } = confirmationMock.confirmContinue.mock.calls[0][0]; + onReject(); + expect(emitSpy).not.toHaveBeenCalled(); - expect(getAvailableProjectsSpy).toHaveBeenCalledWith(''); - jest.useRealTimers(); + const { onConfirm } = confirmationMock.confirmContinue.mock.calls[0][0]; + onConfirm(); + + expect(emitSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit back immediately in backButtonClicked when no create form data', () => { + setup({ detectChanges: false }); + const emitSpy = jest.spyOn(component.backClicked, 'emit'); + + component.backButtonClicked(); + + expect(emitSpy).toHaveBeenCalled(); + expect(confirmationMock.confirmContinue).not.toHaveBeenCalled(); + }); + + it('should compute next button disabled state for create and connect options', () => { + setup({ detectChanges: false }); + component.selectedSupplementOption.set(SupplementOptions.CreateNewProject); + expect(component.isNextButtonDisabled()).toBe(true); + + component.createProjectForm.patchValue({ + [ProjectFormControls.Title]: 'Valid title', + [ProjectFormControls.StorageLocation]: 'region-1', + }); + expect(component.isNextButtonDisabled()).toBe(false); + component.selectedSupplementOption.set(SupplementOptions.ConnectExistingProject); + expect(component.isNextButtonDisabled()).toBe(false); }); }); diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts index c4e58c40b..04fbb95c3 100644 --- a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts +++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts @@ -61,29 +61,33 @@ import { ProjectForm } from '@shared/models/projects/create-project-form.model'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SupplementsStepComponent implements OnInit { - private customConfirmationService = inject(CustomConfirmationService); + private readonly customConfirmationService = inject(CustomConfirmationService); private readonly toastService = inject(ToastService); - private actions = createDispatchMap({ + private readonly destroyRef = inject(DestroyRef); + + private readonly actions = createDispatchMap({ getAvailableProjects: FetchAvailableProjects, connectProject: ConnectProject, disconnectProject: DisconnectProject, fetchPreprintProject: FetchPreprintProject, createNewProject: CreateNewProject, }); - private destroyRef = inject(DestroyRef); - - readonly SupplementOptions = SupplementOptions; - createdPreprint = select(PreprintStepperSelectors.getPreprint); - isPreprintSubmitting = select(PreprintStepperSelectors.isPreprintSubmitting); - availableProjects = select(PreprintStepperSelectors.getAvailableProjects); - areAvailableProjectsLoading = select(PreprintStepperSelectors.areAvailableProjectsLoading); - preprintProject = select(PreprintStepperSelectors.getPreprintProject); - isPreprintProjectLoading = select(PreprintStepperSelectors.isPreprintProjectLoading); + readonly createdPreprint = select(PreprintStepperSelectors.getPreprint); + readonly isPreprintSubmitting = select(PreprintStepperSelectors.isPreprintSubmitting); + readonly availableProjects = select(PreprintStepperSelectors.getAvailableProjects); + readonly areAvailableProjectsLoading = select(PreprintStepperSelectors.areAvailableProjectsLoading); + readonly preprintProject = select(PreprintStepperSelectors.getPreprintProject); + readonly isPreprintProjectLoading = select(PreprintStepperSelectors.isPreprintProjectLoading); selectedSupplementOption = signal(SupplementOptions.None); selectedProjectId = signal(null); + nextClicked = output(); + backClicked = output(); + + readonly SupplementOptions = SupplementOptions; + readonly projectNameControl = new FormControl(null); readonly createProjectForm = new FormGroup({ [ProjectFormControls.Title]: new FormControl('', { @@ -124,20 +128,16 @@ export class SupplementsStepComponent implements OnInit { return; } - untracked(() => { - const preprintProject = this.preprintProject(); - if (preprint.nodeId === preprintProject?.id) { - return; - } - }); + const shouldFetchPreprintProject = untracked(() => preprint.nodeId !== this.preprintProject()?.id); + + if (!shouldFetchPreprintProject) { + return; + } this.actions.fetchPreprintProject(); }); } - nextClicked = output(); - backClicked = output(); - ngOnInit() { this.projectNameControl.valueChanges .pipe(debounceTime(500), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) @@ -164,6 +164,7 @@ export class SupplementsStepComponent implements OnInit { if (!(event.originalEvent instanceof PointerEvent)) { return; } + this.selectedProjectId.set(event.value); this.actions.connectProject(event.value).subscribe({ diff --git a/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.html b/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.html index 01bd85ba5..9091466b8 100644 --- a/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.html +++ b/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.html @@ -64,6 +64,6 @@

{{ 'preprints.preprintStepper.titleAndAbstract.title' | translate }}

tooltipPosition="top" [disabled]="titleAndAbstractForm.invalid" [loading]="isUpdatingPreprint()" - (click)="nextButtonClicked()" + (onClick)="nextButtonClicked()" /> diff --git a/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.spec.ts b/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.spec.ts index a972d6439..95edb3149 100644 --- a/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.spec.ts +++ b/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.spec.ts @@ -1,14 +1,20 @@ -import { MockComponent } from 'ng-mocks'; +import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; + +import { Textarea } from 'primeng/textarea'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; import { TitleAndAbstractStepComponent } from '@osf/features/preprints/components'; import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; +import { ToastService } from '@osf/shared/services/toast.service'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; describe('TitleAndAbstractStepComponent', () => { let component: TitleAndAbstractStepComponent; @@ -16,19 +22,20 @@ describe('TitleAndAbstractStepComponent', () => { const mockPreprint = PREPRINT_MOCK; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TitleAndAbstractStepComponent, OSFTestingModule, MockComponent(TextInputComponent)], + function setup(overrides?: { createdPreprint?: typeof mockPreprint | null; providerId?: string }) { + const mockToastService = ToastServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [TitleAndAbstractStepComponent, MockComponent(TextInputComponent), MockDirective(Textarea)], providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().build()), + MockProvider(ToastService, mockToastService), provideMockStore({ signals: [ { selector: PreprintStepperSelectors.getPreprint, - value: null, - }, - { - selector: PreprintStepperSelectors.getSelectedProviderId, - value: 'provider-1', + value: overrides && 'createdPreprint' in overrides ? overrides.createdPreprint : null, }, { selector: PreprintStepperSelectors.isPreprintSubmitting, @@ -37,137 +44,78 @@ describe('TitleAndAbstractStepComponent', () => { ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(TitleAndAbstractStepComponent); component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize form with empty values', () => { - expect(component.titleAndAbstractForm.get('title')?.value).toBe(''); - expect(component.titleAndAbstractForm.get('description')?.value).toBe(''); - }); - - it('should have form invalid when fields are empty', () => { - expect(component.titleAndAbstractForm.invalid).toBe(true); - }); - - it('should have form valid when fields are filled correctly', () => { + fixture.componentRef.setInput( + 'providerId', + overrides && 'providerId' in overrides ? overrides.providerId : 'provider-1' + ); + fixture.detectChanges(); + } + + function fillValidForm() { component.titleAndAbstractForm.patchValue({ title: 'Valid Title', description: 'Valid description with sufficient length', }); - expect(component.titleAndAbstractForm.valid).toBe(true); - }); + } - it('should validate title max length', () => { - const longTitle = 'a'.repeat(513); - component.titleAndAbstractForm.patchValue({ - title: longTitle, - description: 'Valid description', - }); - expect(component.titleAndAbstractForm.get('title')?.hasError('maxlength')).toBe(true); - }); - - it('should validate description is required', () => { - component.titleAndAbstractForm.patchValue({ - title: 'Valid Title', - description: '', - }); - expect(component.titleAndAbstractForm.get('description')?.hasError('required')).toBe(true); - }); + it('should initialize form with empty values', () => { + setup(); - it('should not proceed when form is invalid', () => { - const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); - component.nextButtonClicked(); - expect(nextClickedSpy).not.toHaveBeenCalled(); + expect(component.titleAndAbstractForm.controls.title.value).toBe(''); + expect(component.titleAndAbstractForm.controls.description.value).toBe(''); + expect(component.titleAndAbstractForm.invalid).toBe(true); }); - it('should emit nextClicked when form is valid and no existing preprint', () => { - component.titleAndAbstractForm.patchValue({ - title: 'Valid Title', - description: 'Valid description with sufficient length', - }); + it('should enforce title and description validation', () => { + setup(); - const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); - component.nextButtonClicked(); - expect(nextClickedSpy).toHaveBeenCalled(); - }); + component.titleAndAbstractForm.patchValue({ title: 'a'.repeat(513), description: 'Valid description' }); + expect(component.titleAndAbstractForm.controls.title.hasError('maxlength')).toBe(true); - it('should initialize form with existing preprint data', () => { - component.titleAndAbstractForm.patchValue({ - title: mockPreprint.title, - description: mockPreprint.description, - }); - expect(component.titleAndAbstractForm.get('title')?.value).toBe(mockPreprint.title); - expect(component.titleAndAbstractForm.get('description')?.value).toBe(mockPreprint.description); - }); + component.titleAndAbstractForm.patchValue({ title: 'Valid title', description: 'Short' }); + expect(component.titleAndAbstractForm.controls.description.hasError('minlength')).toBe(true); - it('should emit nextClicked when form is valid and preprint exists', () => { - component.titleAndAbstractForm.patchValue({ - title: mockPreprint.title, - description: mockPreprint.description, - }); - const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); - component.nextButtonClicked(); - expect(nextClickedSpy).toHaveBeenCalled(); + component.titleAndAbstractForm.patchValue({ title: 'Valid title', description: '' }); + expect(component.titleAndAbstractForm.controls.description.hasError('required')).toBe(true); }); - it('should emit nextClicked when form is valid and no existing preprint', () => { - component.titleAndAbstractForm.patchValue({ - title: 'Test Title', - description: 'Test description with sufficient length', - }); - - const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); - component.nextButtonClicked(); + it('should patch form with existing preprint values', () => { + setup({ createdPreprint: mockPreprint }); - expect(nextClickedSpy).toHaveBeenCalled(); + expect(component.titleAndAbstractForm.controls.title.value).toBe(mockPreprint.title); + expect(component.titleAndAbstractForm.controls.description.value).toBe(mockPreprint.description); }); - it('should emit nextClicked when form is valid and preprint exists', () => { - jest.spyOn(component, 'createdPreprint').mockReturnValue(mockPreprint); - - component.titleAndAbstractForm.patchValue({ - title: 'Updated Title', - description: 'Updated description with sufficient length', - }); + it('should not dispatch or emit when form is invalid', () => { + setup(); + const emitSpy = jest.spyOn(component.nextClicked, 'emit'); - const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); component.nextButtonClicked(); - expect(nextClickedSpy).toHaveBeenCalled(); + expect(emitSpy).not.toHaveBeenCalled(); }); - it('should not emit nextClicked when form is invalid', () => { - const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); + it('should create preprint and emit next when form is valid and no preprint exists', () => { + setup({ createdPreprint: null, providerId: 'provider-1' }); + fillValidForm(); + const emitSpy = jest.spyOn(component.nextClicked, 'emit'); component.nextButtonClicked(); - expect(nextClickedSpy).not.toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalled(); }); - it('should have correct form validation for title and description', () => { - component.titleAndAbstractForm.patchValue({ - title: '', - description: 'Valid description', - }); - expect(component.titleAndAbstractForm.get('title')?.hasError('required')).toBe(true); + it('should update preprint and emit next when form is valid and preprint exists', () => { + setup({ createdPreprint: mockPreprint }); + fillValidForm(); + const emitSpy = jest.spyOn(component.nextClicked, 'emit'); - component.titleAndAbstractForm.patchValue({ - title: 'Valid Title', - description: 'Short', - }); - expect(component.titleAndAbstractForm.get('description')?.hasError('minlength')).toBe(true); + component.nextButtonClicked(); - component.titleAndAbstractForm.patchValue({ - title: 'Valid Title', - description: 'Valid description with sufficient length', - }); - expect(component.titleAndAbstractForm.valid).toBe(true); + expect(emitSpy).toHaveBeenCalled(); }); }); diff --git a/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.ts b/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.ts index cadca4dc6..c15c59dd0 100644 --- a/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.ts +++ b/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.ts @@ -8,7 +8,7 @@ import { Message } from 'primeng/message'; import { Textarea } from 'primeng/textarea'; import { Tooltip } from 'primeng/tooltip'; -import { ChangeDetectionStrategy, Component, effect, inject, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, inject, input, output } from '@angular/core'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { RouterLink } from '@angular/router'; @@ -27,30 +27,36 @@ import { ToastService } from '@osf/shared/services/toast.service'; @Component({ selector: 'osf-title-and-abstract-step', imports: [ - Card, - FormsModule, Button, + Card, Textarea, RouterLink, - ReactiveFormsModule, Tooltip, Message, - TranslatePipe, + FormsModule, + ReactiveFormsModule, TextInputComponent, + TranslatePipe, ], templateUrl: './title-and-abstract-step.component.html', styleUrl: './title-and-abstract-step.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class TitleAndAbstractStepComponent { - private toastService = inject(ToastService); + private readonly toastService = inject(ToastService); + + readonly providerId = input.required(); + readonly nextClicked = output(); - private actions = createDispatchMap({ + private readonly actions = createDispatchMap({ createPreprint: CreatePreprint, updatePreprint: UpdatePreprint, }); - inputLimits = formInputLimits; + readonly createdPreprint = select(PreprintStepperSelectors.getPreprint); + readonly isUpdatingPreprint = select(PreprintStepperSelectors.isPreprintSubmitting); + + readonly inputLimits = formInputLimits; readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; titleAndAbstractForm = new FormGroup({ @@ -68,12 +74,6 @@ export class TitleAndAbstractStepComponent { }), }); - createdPreprint = select(PreprintStepperSelectors.getPreprint); - providerId = select(PreprintStepperSelectors.getSelectedProviderId); - - isUpdatingPreprint = select(PreprintStepperSelectors.isPreprintSubmitting); - nextClicked = output(); - constructor() { effect(() => { const createdPreprint = this.createdPreprint(); @@ -86,22 +86,23 @@ export class TitleAndAbstractStepComponent { }); } - nextButtonClicked() { + nextButtonClicked(): void { if (this.titleAndAbstractForm.invalid) { return; } - const model = this.titleAndAbstractForm.value; + const model = this.titleAndAbstractForm.getRawValue(); + const createdPreprint = this.createdPreprint(); - if (this.createdPreprint()) { - this.actions.updatePreprint(this.createdPreprint()!.id, model).subscribe({ + if (createdPreprint) { + this.actions.updatePreprint(createdPreprint.id, model).subscribe({ complete: () => { this.nextClicked.emit(); this.toastService.showSuccess('preprints.preprintStepper.common.successMessages.preprintSaved'); }, }); } else { - this.actions.createPreprint(model.title!, model.description!, this.providerId()!).subscribe({ + this.actions.createPreprint(model.title, model.description, this.providerId()).subscribe({ complete: () => { this.nextClicked.emit(); this.toastService.showSuccess('preprints.preprintStepper.common.successMessages.preprintSaved'); 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 cbd1794d8..42bc5e0e6 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 @@ -39,7 +39,7 @@

} @case (PreprintSteps.Review) { - + } } diff --git a/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts b/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts index 122819273..ae39ff284 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 @@ -31,12 +31,7 @@ import { FileStepComponent, ReviewStepComponent } from '../../components'; import { createNewVersionStepsConst } from '../../constants'; import { PreprintSteps } from '../../enums'; import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { - FetchPreprintById, - PreprintStepperSelectors, - ResetPreprintStepperState, - SetSelectedPreprintProviderId, -} from '../../store/preprint-stepper'; +import { FetchPreprintById, PreprintStepperSelectors, ResetPreprintStepperState } from '../../store/preprint-stepper'; @Component({ selector: 'osf-create-new-version', @@ -59,20 +54,20 @@ export class CreateNewVersionComponent implements OnDestroy, CanDeactivateCompon private actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, - setSelectedPreprintProviderId: SetSelectedPreprintProviderId, - resetState: ResetPreprintStepperState, fetchPreprint: FetchPreprintById, + resetState: ResetPreprintStepperState, }); - readonly PreprintSteps = PreprintSteps; - readonly newVersionSteps = createNewVersionStepsConst; - preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); + currentStep = signal(createNewVersionStepsConst[0]); isWeb = toSignal(inject(IS_WEB)); + readonly PreprintSteps = PreprintSteps; + readonly newVersionSteps = createNewVersionStepsConst; + constructor() { this.actions.getPreprintProviderById(this.providerId()); this.actions.fetchPreprint(this.preprintId()); @@ -81,7 +76,6 @@ export class CreateNewVersionComponent implements OnDestroy, CanDeactivateCompon const provider = this.preprintProvider(); if (provider) { - this.actions.setSelectedPreprintProviderId(provider.id); this.brandService.applyBranding(provider.brand); this.headerStyleHelper.applyHeaderStyles( provider.brand.primaryColor, 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 6d77b585c..9c9ddef48 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 @@ -9,7 +9,6 @@ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header import { PreprintProviderShortInfo } from '../../models'; import { GetPreprintProvidersAllowingSubmissions, PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { PreprintStepperSelectors, SetSelectedPreprintProviderId } from '../../store/preprint-stepper'; import { SelectPreprintServiceComponent } from './select-preprint-service.component'; @@ -29,7 +28,6 @@ describe('SelectPreprintServiceComponent', () => { const defaultSignals: SignalOverride[] = [ { selector: PreprintProvidersSelectors.getPreprintProvidersAllowingSubmissions, value: mockProviders }, { selector: PreprintProvidersSelectors.arePreprintProvidersAllowingSubmissionsLoading, value: false }, - { selector: PreprintStepperSelectors.getSelectedProviderId, value: null }, ]; function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { @@ -73,26 +71,24 @@ describe('SelectPreprintServiceComponent', () => { component.toggleProviderSelection(mockProvider); - expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedPreprintProviderId(mockProvider.id)); + expect(component.selectedProviderId()).toBe(mockProvider.id); }); - it('should dispatch deselect action when toggling the already selected provider', () => { - setup({ - selectorOverrides: [{ selector: PreprintStepperSelectors.getSelectedProviderId, value: mockProvider.id }], - }); + it('should deselect when toggling the already selected provider', () => { + setup(); + component.selectedProviderId.set(mockProvider.id); component.toggleProviderSelection(mockProvider); - expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedPreprintProviderId(null)); + expect(component.selectedProviderId()).toBeNull(); }); - it('should dispatch select action when toggling a different provider', () => { - setup({ - selectorOverrides: [{ selector: PreprintStepperSelectors.getSelectedProviderId, value: 'other-provider' }], - }); + it('should select when toggling a different provider', () => { + setup(); + component.selectedProviderId.set('other-provider'); component.toggleProviderSelection(mockProvider); - expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedPreprintProviderId(mockProvider.id)); + expect(component.selectedProviderId()).toBe(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 e47218751..842d11f5e 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,7 +8,7 @@ import { Skeleton } from 'primeng/skeleton'; import { Tooltip } from 'primeng/tooltip'; import { NgClass } from '@angular/common'; -import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core'; +import { ChangeDetectionStrategy, Component, HostBinding, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; @@ -16,7 +16,6 @@ import { SafeHtmlPipe } from '@osf/shared/pipes/safe-html.pipe'; import { PreprintProviderShortInfo } from '../../models'; import { GetPreprintProvidersAllowingSubmissions, PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { PreprintStepperSelectors, SetSelectedPreprintProviderId } from '../../store/preprint-stepper'; @Component({ selector: 'osf-select-preprint-service', @@ -30,12 +29,12 @@ export class SelectPreprintServiceComponent { private actions = createDispatchMap({ getPreprintProvidersAllowingSubmissions: GetPreprintProvidersAllowingSubmissions, - setSelectedPreprintProviderId: SetSelectedPreprintProviderId, }); preprintProvidersAllowingSubmissions = select(PreprintProvidersSelectors.getPreprintProvidersAllowingSubmissions); areProvidersLoading = select(PreprintProvidersSelectors.arePreprintProvidersAllowingSubmissionsLoading); - selectedProviderId = select(PreprintStepperSelectors.getSelectedProviderId); + + selectedProviderId = signal(null); skeletonArray = new Array(8); constructor() { @@ -44,10 +43,10 @@ export class SelectPreprintServiceComponent { toggleProviderSelection(provider: PreprintProviderShortInfo): void { if (provider.id === this.selectedProviderId()) { - this.actions.setSelectedPreprintProviderId(null); + this.selectedProviderId.set(null); return; } - this.actions.setSelectedPreprintProviderId(provider.id); + this.selectedProviderId.set(provider.id); } } 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 2fa6ce3cc..38a89d790 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 @@ -36,7 +36,7 @@

@switch (currentStep().value) { @case (PreprintSteps.TitleAndAbstract) { - + } @case (PreprintSteps.File) { 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 21e5c3ac2..bb0e6f7a2 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 @@ -40,12 +40,7 @@ import { import { submitPreprintSteps } from '../../constants'; import { PreprintSteps } from '../../enums'; import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { - DeletePreprint, - PreprintStepperSelectors, - ResetPreprintStepperState, - SetSelectedPreprintProviderId, -} from '../../store/preprint-stepper'; +import { DeletePreprint, PreprintStepperSelectors, ResetPreprintStepperState } from '../../store/preprint-stepper'; @Component({ selector: 'osf-submit-preprint-stepper', @@ -77,7 +72,6 @@ export class SubmitPreprintStepperComponent implements OnDestroy, CanDeactivateC private actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, - setSelectedPreprintProviderId: SetSelectedPreprintProviderId, resetState: ResetPreprintStepperState, deletePreprint: DeletePreprint, }); @@ -109,7 +103,6 @@ export class SubmitPreprintStepperComponent implements OnDestroy, CanDeactivateC const provider = this.preprintProvider(); if (provider) { - this.actions.setSelectedPreprintProviderId(provider.id); this.brandService.applyBranding(provider.brand); this.headerStyleHelper.applyHeaderStyles( provider.brand.primaryColor, 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 6a82b8ad0..6e19e1bd8 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 @@ -36,16 +36,16 @@

@switch (currentStep().value) { @case (PreprintSteps.TitleAndAbstract) { - + } @case (PreprintSteps.File) { } @case (PreprintSteps.Metadata) { } @case (PreprintSteps.AuthorAssertions) { 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 455e67cb9..eacb8c11e 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 @@ -39,12 +39,7 @@ import { import { submitPreprintSteps } from '../../constants'; import { PreprintSteps, ProviderReviewsWorkflow, ReviewsState } from '../../enums'; import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { - FetchPreprintById, - PreprintStepperSelectors, - ResetPreprintStepperState, - SetSelectedPreprintProviderId, -} from '../../store/preprint-stepper'; +import { FetchPreprintById, PreprintStepperSelectors, ResetPreprintStepperState } from '../../store/preprint-stepper'; @Component({ selector: 'osf-update-preprint-stepper', @@ -76,7 +71,6 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC private actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, - setSelectedPreprintProviderId: SetSelectedPreprintProviderId, resetState: ResetPreprintStepperState, fetchPreprint: FetchPreprintById, }); @@ -129,7 +123,6 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC const provider = this.preprintProvider(); if (provider) { - this.actions.setSelectedPreprintProviderId(provider.id); this.brandService.applyBranding(provider.brand); this.headerStyleHelper.applyHeaderStyles( provider.brand.primaryColor, diff --git a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.actions.ts b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.actions.ts index 23d62a456..4e35cfd0c 100644 --- a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.actions.ts +++ b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.actions.ts @@ -6,12 +6,6 @@ import { LicenseOptions } from '@osf/shared/models/license/license.model'; import { PreprintFileSource } from '../../enums'; import { PreprintModel } from '../../models'; -export class SetSelectedPreprintProviderId { - static readonly type = '[Preprint Stepper] Set Selected Preprint Provider Id'; - - constructor(public id: StringOrNull) {} -} - export class CreatePreprint { static readonly type = '[Preprint Stepper] Create Preprint'; @@ -98,6 +92,8 @@ export class FetchProjectFilesByLink { export class FetchLicenses { static readonly type = '[Preprint Stepper] Fetch Licenses'; + + constructor(public providerId: string) {} } export class SaveLicense { diff --git a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.model.ts b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.model.ts index dc64f8289..7fdbee39c 100644 --- a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.model.ts +++ b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.model.ts @@ -1,4 +1,3 @@ -import { StringOrNull } from '@osf/shared/helpers/types.helper'; import { IdNameModel } from '@osf/shared/models/common/id-name.model'; import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; @@ -10,7 +9,6 @@ import { PreprintFileSource } from '../../enums'; import { PreprintFilesLinks, PreprintModel } from '../../models'; export interface PreprintStepperStateModel { - selectedProviderId: StringOrNull; preprint: AsyncStateModel; fileSource: PreprintFileSource; preprintFilesLinks: AsyncStateModel; @@ -25,7 +23,6 @@ export interface PreprintStepperStateModel { } export const DEFAULT_PREPRINT_STEPPER_STATE: PreprintStepperStateModel = { - selectedProviderId: null, preprint: { data: null, isLoading: false, 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 43f5e74c6..f88c8eaf9 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 @@ -4,11 +4,6 @@ import { PreprintStepperState, PreprintStepperStateModel } from '@osf/features/p import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; export class PreprintStepperSelectors { - @Selector([PreprintStepperState]) - static getSelectedProviderId(state: PreprintStepperStateModel) { - return state.selectedProviderId; - } - @Selector([PreprintStepperState]) static getPreprint(state: PreprintStepperStateModel) { return state.preprint.data; diff --git a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts index 1e1eb3bd1..cc817558d 100644 --- a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts +++ b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts @@ -41,7 +41,6 @@ import { SetPreprintStepperCurrentFolder, SetProjectRootFolder, SetSelectedPreprintFileSource, - SetSelectedPreprintProviderId, SubmitPreprint, UpdatePreprint, UpdatePrimaryFileRelationship, @@ -61,11 +60,6 @@ export class PreprintStepperState { private licensesService = inject(PreprintLicensesService); private preprintProjectsService = inject(PreprintsProjectsService); - @Action(SetSelectedPreprintProviderId) - setSelectedPreprintProviderId(ctx: StateContext, action: SetSelectedPreprintProviderId) { - ctx.patchState({ selectedProviderId: action.id }); - } - @Action(CreatePreprint) createPreprint(ctx: StateContext, action: CreatePreprint) { ctx.setState(patch({ preprint: patch({ isSubmitting: true }) })); @@ -323,12 +317,10 @@ export class PreprintStepperState { } @Action(FetchLicenses) - fetchLicenses(ctx: StateContext) { - const providerId = ctx.getState().selectedProviderId; - if (!providerId) return; + fetchLicenses(ctx: StateContext, action: FetchLicenses) { ctx.setState(patch({ licenses: patch({ isLoading: true }) })); - return this.licensesService.getLicenses(providerId).pipe( + return this.licensesService.getLicenses(action.providerId).pipe( tap((licenses) => { ctx.setState(patch({ licenses: patch({ isLoading: false, data: licenses }) })); }), diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index b6f5f392a..c002b8475 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2175,11 +2175,6 @@ "uploadFromComputer": "Upload From Your Computer", "selectFromProject": "Select From Existing OSF Project", "uploadFileButton": "Upload file", - "projectSelection": { - "title": "Project Title", - "description": "This will make your project public, if it is not already", - "subDescription": "The projects and components for which you have admin access are listed below." - }, "versionFile": { "header": "Add a new preprint file", "message": "This will allow a new version of the preprint file to be uploaded to the preprint. The existing file will be retained as a version of the preprint." @@ -2227,11 +2222,6 @@ "connectExisting": "Connect An Existing OSF Project", "createNew": "Create A New OSF Project" }, - "projectSelection": { - "title": "Select Project", - "description": "This will make your project public, if it is not already", - "subDescription": "The projects and components for which you have admin access are listed below." - }, "disconnectProject": { "header": "Disconnect supplemental material", "message": "This will disconnect the selected project. You can select new supplemental material or re-add the same supplemental material at a later date." @@ -2284,6 +2274,11 @@ } } }, + "projectSelection": { + "selectProject": "Select Project", + "description": "This will make your project public, if it is not already", + "subDescription": "The projects and components for which you have admin access are listed below." + }, "common": { "validation": { "fillRequiredFields": "Fill in “Required” fields to continue" diff --git a/src/styles/overrides/select.scss b/src/styles/overrides/select.scss index 92375d05c..6aa14352c 100644 --- a/src/styles/overrides/select.scss +++ b/src/styles/overrides/select.scss @@ -45,3 +45,9 @@ min-width: 16rem; } } + +#project-select { + .p-select-label { + font-size: 0.875rem; + } +} From 18f35e0f8c64315983f32700d451a42b52c12dfe Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 26 Feb 2026 16:16:33 +0200 Subject: [PATCH 6/7] fix(t): t --- .cursor/rules/unit-testing-conventions.mdc | 103 +++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 .cursor/rules/unit-testing-conventions.mdc diff --git a/.cursor/rules/unit-testing-conventions.mdc b/.cursor/rules/unit-testing-conventions.mdc new file mode 100644 index 000000000..1d9c1ce69 --- /dev/null +++ b/.cursor/rules/unit-testing-conventions.mdc @@ -0,0 +1,103 @@ +--- +description: Unit testing conventions for Angular component specs +globs: **/*.spec.ts +alwaysApply: false +--- + +# Unit Testing Conventions + +## Store & Actions + +- Use `provideMockStore()` from `@testing/providers/store-provider.mock` for all store mocking. +- Use `mergeSignalOverrides(defaults, overrides)` from the same module to apply selector overrides in `setup()` helpers — never inline the merge logic. +- Inject the mock store via `store = TestBed.inject(Store)` and assert on `store.dispatch`. +- Never use `Object.defineProperty` to mock `component.actions` — it bypasses `createDispatchMap` entirely. +- Only provide `actions` config in `provideMockStore({ actions: [...] })` when component logic reads the dispatch return value. The default `jest.fn()` returning `of(true)` is sufficient otherwise. +- Use `(store.dispatch as jest.Mock).mockClear()` when `ngOnInit` dispatches and you need clean assertions per test. + +## Providers + +- Use `provideOSFCore()` from `@testing/osf.testing.provider.ts` instead of `OSFTestingModule`. +- Use `MockProvider` from ng-mocks as the default for providing mock services: `MockProvider(ToastService, ToastServiceMock.simple())`. +- When the test needs `jest.fn()` methods (`.mockImplementation`, `.mockClear`, assertions), pass an explicit mock as the second argument — bare `MockProvider(Service)` creates ng-mocks stubs, not `jest.fn()`. +- Use builders from `@testing/providers/` to construct non-trivial mocks: `ActivatedRouteMockBuilder`, `RouterMockBuilder`, `CustomDialogServiceMockBuilder`. Pass the built result to `MockProvider`: `MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().withParams({ id: '1' }).build())`. +- Use `withNoParent()` on `ActivatedRouteMockBuilder` when testing components that guard against a missing parent route — never cast `{ parent: null } as Partial` inline. +- Use `.simple()` factories for common mock shapes: `ToastServiceMock.simple()`, `CustomConfirmationServiceMock.simple()`. +- Use `provideDynamicDialogRefMock()` for dialog components — it creates a real `Subject` for `onClose` that `MockProvider` cannot auto-generate. +- Use `provideMockStore()` from `@testing/providers/store-provider.mock` for store mocking. +- Check `@testing/` for existing mocks and builders before creating inline mocks. + +## Running Tests + +- Run a specific spec file with `npm test `. Example: `npm test auth.interceptor.spec`. +- Add `--no-coverage` to skip the coverage report for faster feedback. +- The `working_directory` must be the workspace root (`d:\OSF\angular-osf`). + +## Test Structure + +- Prefer a single flat `describe` block per file to keep tests searchable and prevent state leakage. Use nested `describe` blocks when it significantly simplifies setup or groups logically distinct behaviors. +- For specs where all tests share a single configuration, use `beforeEach` with `TestBed.configureTestingModule` directly. Use a `setup()` helper when tests need different selector values, route configs, or other overrides. +- When using `setup()`, extend `BaseSetupOverrides` from `@testing/providers/store-provider.mock` for the standard route/selector shape. Add component-specific fields as needed. +- No `TestBed.resetTestingModule()` in `afterEach` — Angular auto-resets. +- No redundant tests — merge tests that cover the same code path. +- Use actual interfaces/types for mock data instead of `any`. +- Use `@docs/testing.md` for rules in unit testing. + +## Example + +```typescript +import { Store } from '@ngxs/store'; +import { MockProvider } from 'ng-mocks'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { MySelectors, MyAction } from '@osf/features/my-feature/store'; +import { MyComponent } from './my.component'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +describe('MyComponent', () => { + let component: MyComponent; + let fixture: ComponentFixture; + let store: Store; + let mockRouter: RouterMockType; + let toastService: ToastServiceMockType; + + beforeEach(() => { + const mockRoute = ActivatedRouteMockBuilder.create().withParams({ id: '1' }).build(); + mockRouter = RouterMockBuilder.create().withUrl('/x').build(); + toastService = ToastServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [MyComponent], + providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(Router, mockRouter), + MockProvider(ToastService, toastService), + provideMockStore({ + signals: [{ selector: MySelectors.getData, value: [] }], + }), + ], + }); + + store = TestBed.inject(Store); + fixture = TestBed.createComponent(MyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should dispatch action and show toast', () => { + component.doSomething(); + expect(store.dispatch).toHaveBeenCalledWith(new MyAction('1')); + expect(toastService.showSuccess).toHaveBeenCalledWith('done'); + }); +}); +``` From df6f54521a65acc77bbfbf0a8256be6d3de158b6 Mon Sep 17 00:00:00 2001 From: nsemets Date: Fri, 27 Feb 2026 16:24:15 +0200 Subject: [PATCH 7/7] test(preprints): removed file --- .cursor/rules/unit-testing-conventions.mdc | 103 --------------------- 1 file changed, 103 deletions(-) delete mode 100644 .cursor/rules/unit-testing-conventions.mdc diff --git a/.cursor/rules/unit-testing-conventions.mdc b/.cursor/rules/unit-testing-conventions.mdc deleted file mode 100644 index 1d9c1ce69..000000000 --- a/.cursor/rules/unit-testing-conventions.mdc +++ /dev/null @@ -1,103 +0,0 @@ ---- -description: Unit testing conventions for Angular component specs -globs: **/*.spec.ts -alwaysApply: false ---- - -# Unit Testing Conventions - -## Store & Actions - -- Use `provideMockStore()` from `@testing/providers/store-provider.mock` for all store mocking. -- Use `mergeSignalOverrides(defaults, overrides)` from the same module to apply selector overrides in `setup()` helpers — never inline the merge logic. -- Inject the mock store via `store = TestBed.inject(Store)` and assert on `store.dispatch`. -- Never use `Object.defineProperty` to mock `component.actions` — it bypasses `createDispatchMap` entirely. -- Only provide `actions` config in `provideMockStore({ actions: [...] })` when component logic reads the dispatch return value. The default `jest.fn()` returning `of(true)` is sufficient otherwise. -- Use `(store.dispatch as jest.Mock).mockClear()` when `ngOnInit` dispatches and you need clean assertions per test. - -## Providers - -- Use `provideOSFCore()` from `@testing/osf.testing.provider.ts` instead of `OSFTestingModule`. -- Use `MockProvider` from ng-mocks as the default for providing mock services: `MockProvider(ToastService, ToastServiceMock.simple())`. -- When the test needs `jest.fn()` methods (`.mockImplementation`, `.mockClear`, assertions), pass an explicit mock as the second argument — bare `MockProvider(Service)` creates ng-mocks stubs, not `jest.fn()`. -- Use builders from `@testing/providers/` to construct non-trivial mocks: `ActivatedRouteMockBuilder`, `RouterMockBuilder`, `CustomDialogServiceMockBuilder`. Pass the built result to `MockProvider`: `MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().withParams({ id: '1' }).build())`. -- Use `withNoParent()` on `ActivatedRouteMockBuilder` when testing components that guard against a missing parent route — never cast `{ parent: null } as Partial` inline. -- Use `.simple()` factories for common mock shapes: `ToastServiceMock.simple()`, `CustomConfirmationServiceMock.simple()`. -- Use `provideDynamicDialogRefMock()` for dialog components — it creates a real `Subject` for `onClose` that `MockProvider` cannot auto-generate. -- Use `provideMockStore()` from `@testing/providers/store-provider.mock` for store mocking. -- Check `@testing/` for existing mocks and builders before creating inline mocks. - -## Running Tests - -- Run a specific spec file with `npm test `. Example: `npm test auth.interceptor.spec`. -- Add `--no-coverage` to skip the coverage report for faster feedback. -- The `working_directory` must be the workspace root (`d:\OSF\angular-osf`). - -## Test Structure - -- Prefer a single flat `describe` block per file to keep tests searchable and prevent state leakage. Use nested `describe` blocks when it significantly simplifies setup or groups logically distinct behaviors. -- For specs where all tests share a single configuration, use `beforeEach` with `TestBed.configureTestingModule` directly. Use a `setup()` helper when tests need different selector values, route configs, or other overrides. -- When using `setup()`, extend `BaseSetupOverrides` from `@testing/providers/store-provider.mock` for the standard route/selector shape. Add component-specific fields as needed. -- No `TestBed.resetTestingModule()` in `afterEach` — Angular auto-resets. -- No redundant tests — merge tests that cover the same code path. -- Use actual interfaces/types for mock data instead of `any`. -- Use `@docs/testing.md` for rules in unit testing. - -## Example - -```typescript -import { Store } from '@ngxs/store'; -import { MockProvider } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; -import { ToastService } from '@osf/shared/services/toast.service'; -import { MySelectors, MyAction } from '@osf/features/my-feature/store'; -import { MyComponent } from './my.component'; -import { provideOSFCore } from '@testing/osf.testing.provider'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; - -describe('MyComponent', () => { - let component: MyComponent; - let fixture: ComponentFixture; - let store: Store; - let mockRouter: RouterMockType; - let toastService: ToastServiceMockType; - - beforeEach(() => { - const mockRoute = ActivatedRouteMockBuilder.create().withParams({ id: '1' }).build(); - mockRouter = RouterMockBuilder.create().withUrl('/x').build(); - toastService = ToastServiceMock.simple(); - - TestBed.configureTestingModule({ - imports: [MyComponent], - providers: [ - provideOSFCore(), - MockProvider(ActivatedRoute, mockRoute), - MockProvider(Router, mockRouter), - MockProvider(ToastService, toastService), - provideMockStore({ - signals: [{ selector: MySelectors.getData, value: [] }], - }), - ], - }); - - store = TestBed.inject(Store); - fixture = TestBed.createComponent(MyComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should dispatch action and show toast', () => { - component.doSomething(); - expect(store.dispatch).toHaveBeenCalledWith(new MyAction('1')); - expect(toastService.showSuccess).toHaveBeenCalledWith('done'); - }); -}); -```