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/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) {
+
+
+
-
-
-
+
+
+
+}
@@ -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()?.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)"
/>
{{ 'preprints.preprintStepper.supplements.title' | translate }}
styleClass="w-full"
[label]="'preprints.preprintStepper.supplements.options.createNew' | translate"
severity="secondary"
- (click)="selectSupplementOption(SupplementOptions.CreateNewProject)"
+ (onClick)="selectSupplementOption(SupplementOptions.CreateNewProject)"
/>
@@ -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/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/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..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
@@ -6,9 +6,9 @@
} @else {
{{ 'preprints.createNewVersionTitle' | translate }}
@@ -30,17 +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/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..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
@@ -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';
@@ -32,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',
@@ -46,7 +40,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,32 +49,33 @@ 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,
- setSelectedPreprintProviderId: SetSelectedPreprintProviderId,
- resetState: ResetPreprintStepperState,
fetchPreprint: FetchPreprintById,
+ resetState: ResetPreprintStepperState,
});
- readonly PreprintSteps = PreprintSteps;
- readonly newVersionSteps = createNewVersionStepsConst;
-
- preprint = select(PreprintStepperSelectors.getPreprint);
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());
+
effect(() => {
const provider = this.preprintProvider();
if (provider) {
- this.actions.setSelectedPreprintProviderId(provider.id);
this.brandService.applyBranding(provider.brand);
this.headerStyleHelper.applyHeaderStyles(
provider.brand.primaryColor,
@@ -93,14 +88,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 +101,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 @@
>
- {{ 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.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()"
/>
-
+
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 77%
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..10735e837 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 {
}
@@ -32,34 +34,30 @@ {{ '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 }}
}
-
+
{
+ 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..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
@@ -1,137 +1,94 @@
-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 { 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 },
+ ];
+
+ 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(component.selectedProviderId()).toBe(mockProvider.id);
});
- it('should handle provider selection when provider is not selected', () => {
- const provider = mockProviders[0];
- component.selectDeselectProvider(provider);
+ it('should deselect when toggling the already selected provider', () => {
+ setup();
+ component.selectedProviderId.set(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(component.selectedProviderId()).toBeNull();
});
- it('should handle empty providers array', () => {
- const providers = component.preprintProvidersAllowingSubmissions();
- expect(providers).toBeDefined();
- expect(Array.isArray(providers)).toBe(true);
- });
+ it('should select when toggling a different provider', () => {
+ setup();
+ component.selectedProviderId.set('other-provider');
+
+ component.toggleProviderSelection(mockProvider);
- it('should handle null selected provider ID', () => {
- const selectedId = component.selectedProviderId();
- expect(selectedId).toBeDefined();
+ 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 c726d4ac5..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,45 +8,45 @@ 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, signal } 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';
-import { PreprintStepperSelectors, SetSelectedPreprintProviderId } from '../../store/preprint-stepper';
@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({
getPreprintProvidersAllowingSubmissions: GetPreprintProvidersAllowingSubmissions,
- setSelectedPreprintProviderId: SetSelectedPreprintProviderId,
});
preprintProvidersAllowingSubmissions = select(PreprintProvidersSelectors.getPreprintProvidersAllowingSubmissions);
areProvidersLoading = select(PreprintProvidersSelectors.arePreprintProvidersAllowingSubmissionsLoading);
- selectedProviderId = select(PreprintStepperSelectors.getSelectedProviderId);
- skeletonArray = Array.from({ length: 8 }, (_, i) => i + 1);
- ngOnInit(): void {
+ selectedProviderId = signal(null);
+ skeletonArray = new Array(8);
+
+ constructor() {
this.actions.getPreprintProvidersAllowingSubmissions();
}
- selectDeselectProvider(provider: PreprintProviderShortInfo) {
+ 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 fd53f63ae..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
@@ -6,9 +6,9 @@
} @else {
{{ 'preprints.addPreprint' | translate: { preprintWord: preprintProvider()!.preprintWord } }}
@@ -22,7 +22,7 @@
} @else {
@@ -30,33 +30,33 @@
}
-
- @switch (currentStep().value) {
- @case (SubmitStepsEnum.TitleAndAbstract) {
-
- }
- @case (SubmitStepsEnum.File) {
-
- }
- @case (SubmitStepsEnum.Metadata) {
-
- }
- @case (SubmitStepsEnum.AuthorAssertions) {
-
- }
- @case (SubmitStepsEnum.Supplements) {
-
- }
- @case (SubmitStepsEnum.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/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..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
@@ -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';
@@ -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',
@@ -57,7 +52,6 @@ import {
PreprintsMetadataStepComponent,
AuthorAssertionsStepComponent,
SupplementsStepComponent,
- AuthorAssertionsStepComponent,
ReviewStepComponent,
TranslatePipe,
],
@@ -65,24 +59,24 @@ 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,
- setSelectedPreprintProviderId: SetSelectedPreprintProviderId,
resetState: ResetPreprintStepperState,
deletePreprint: DeletePreprint,
});
- readonly SubmitStepsEnum = PreprintSteps;
+ readonly PreprintSteps = PreprintSteps;
preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId()));
isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading);
@@ -90,7 +84,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,26 +92,17 @@ 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();
if (provider) {
- this.actions.setSelectedPreprintProviderId(provider.id);
this.brandService.applyBranding(provider.brand);
this.headerStyleHelper.applyHeaderStyles(
provider.brand.primaryColor,
@@ -129,12 +114,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 +134,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 +142,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..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
@@ -6,9 +6,9 @@
} @else {
{{ 'preprints.updatePreprint' | translate: { preprintWord: preprintProvider()!.preprintWord } }}
@@ -30,33 +30,33 @@
}
-
- @switch (currentStep().value) {
- @case (PreprintSteps.TitleAndAbstract) {
-
- }
- @case (SubmitStepsEnum.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.spec.ts b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts
index 7a57296bf..545d5def9 100644
--- a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts
+++ b/src/app/features/preprints/pages/update-preprint-stepper/update-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';
@@ -21,42 +22,57 @@ import {
TitleAndAbstractStepComponent,
} from '../../components';
import { submitPreprintSteps } from '../../constants';
-import { PreprintSteps } from '../../enums';
+import { PreprintSteps, ReviewsState } 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 { UpdatePreprintStepperComponent } from './update-preprint-stepper.component';
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 { 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('UpdatePreprintStepperComponent', () => {
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..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
@@ -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';
@@ -41,18 +39,13 @@ 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',
imports: [
- AuthorAssertionsStepComponent,
Skeleton,
+ AuthorAssertionsStepComponent,
StepperComponent,
TitleAndAbstractStepComponent,
PreprintsMetadataStepComponent,
@@ -65,7 +58,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,12 +66,11 @@ 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,
- setSelectedPreprintProviderId: SetSelectedPreprintProviderId,
resetState: ResetPreprintStepperState,
fetchPreprint: FetchPreprintById,
});
@@ -87,8 +79,13 @@ export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDea
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]);
- currentUserIsAdmin = computed(() => this.preprint()?.currentUserPermissions.includes(UserPermissions.Admin) || false);
+ readonly PreprintSteps = PreprintSteps;
editAndResubmitMode = computed(() => {
const providerIsPremod = this.preprintProvider()?.reviewsWorkflow === ProviderReviewsWorkflow.PreModeration;
@@ -106,44 +103,26 @@ export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDea
}
return submitPreprintSteps
- .map((step) => {
- if (step.value !== PreprintSteps.File) {
- return step;
+ .filter((step) => {
+ if (step.value === PreprintSteps.File) {
+ return this.editAndResubmitMode();
}
-
- return this.editAndResubmitMode() ? step : null;
- })
- .filter((step) => step !== null)
- .map((step) => {
- if (step.value !== PreprintSteps.AuthorAssertions) {
- return step;
- }
-
- 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();
if (provider) {
- this.actions.setSelectedPreprintProviderId(provider.id);
this.brandService.applyBranding(provider.brand);
this.headerStyleHelper.applyHeaderStyles(
provider.brand.primaryColor,
@@ -156,20 +135,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 +153,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..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: '',
@@ -31,7 +32,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.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 09b9cb09a..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
@@ -1,13 +1,9 @@
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])
- static getSelectedProviderId(state: PreprintStepperStateModel) {
- return state.selectedProviderId;
- }
-
@Selector([PreprintStepperState])
static getPreprint(state: PreprintStepperStateModel) {
return state.preprint.data;
@@ -88,6 +84,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/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/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();
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 {
-
+
}
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;
+ }
+}
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(),
+ };
+ },
+};