From 26f117ea3a64b6c16e8ef8d59d7ffc73945b0e40 Mon Sep 17 00:00:00 2001 From: nsemets Date: Fri, 30 Jan 2026 18:24:46 +0200 Subject: [PATCH 01/27] [ENG-10048] Add Registry Name to Registration Metadata (#858) - Ticket: [ENG-10048] - Feature flag: n/a ## Summary of Changes 1. Added registry info to overview and metadata pages. --- .../metadata-date-info.component.html | 4 +- .../metadata-registry-info.component.html | 13 ++ .../metadata-registry-info.component.scss | 0 .../metadata-registry-info.component.spec.ts | 130 ++++++++++++++++++ .../metadata-registry-info.component.ts | 19 +++ .../metadata/mappers/metadata.mapper.ts | 1 + .../features/metadata/metadata.component.html | 4 + .../metadata/metadata.component.spec.ts | 2 + .../features/metadata/metadata.component.ts | 4 + .../models/metadata-json-api.model.ts | 1 + .../metadata/models/metadata.model.ts | 1 + .../registry-overview-metadata.component.html | 5 + ...gistry-overview-metadata.component.spec.ts | 2 + .../registry-overview-metadata.component.ts | 2 + src/assets/i18n/en.json | 1 + 15 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.html create mode 100644 src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.scss create mode 100644 src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.spec.ts create mode 100644 src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.ts diff --git a/src/app/features/metadata/components/metadata-date-info/metadata-date-info.component.html b/src/app/features/metadata/components/metadata-date-info/metadata-date-info.component.html index b4c1d5fa2..b83f434ad 100644 --- a/src/app/features/metadata/components/metadata-date-info/metadata-date-info.component.html +++ b/src/app/features/metadata/components/metadata-date-info/metadata-date-info.component.html @@ -5,7 +5,7 @@

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

-

+

{{ dateCreated() | date: dateFormat }}

@@ -15,7 +15,7 @@

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

-

+

{{ dateModified() | date: dateFormat }}

diff --git a/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.html b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.html new file mode 100644 index 000000000..3f1465c0d --- /dev/null +++ b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.html @@ -0,0 +1,13 @@ + +
+
+

{{ 'registry.overview.metadata.type' | translate }}

+

{{ type() }}

+
+ +
+

{{ 'registry.overview.metadata.registry' | translate }}

+

{{ provider()?.name }}

+
+
+
diff --git a/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.scss b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.spec.ts b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.spec.ts new file mode 100644 index 000000000..8dacb5d07 --- /dev/null +++ b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.spec.ts @@ -0,0 +1,130 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RegistryProviderDetails } from '@osf/shared/models/provider/registry-provider.model'; + +import { MetadataRegistryInfoComponent } from './metadata-registry-info.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('MetadataRegistryInfoComponent', () => { + let component: MetadataRegistryInfoComponent; + let fixture: ComponentFixture; + + const mockProvider: RegistryProviderDetails = { + id: 'test-provider-id', + name: 'Test Registry Provider', + descriptionHtml: '

Test description

', + permissions: [], + brand: null, + iri: 'https://example.com/registry', + reviewsWorkflow: 'standard', + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MetadataRegistryInfoComponent, OSFTestingModule], + }).compileComponents(); + + fixture = TestBed.createComponent(MetadataRegistryInfoComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize with default values', () => { + expect(component.type()).toBe(''); + expect(component.provider()).toBeUndefined(); + }); + + it('should set type input', () => { + const mockType = 'Clinical Trial'; + fixture.componentRef.setInput('type', mockType); + fixture.detectChanges(); + + expect(component.type()).toBe(mockType); + }); + + it('should set provider input', () => { + fixture.componentRef.setInput('provider', mockProvider); + fixture.detectChanges(); + + expect(component.provider()).toEqual(mockProvider); + }); + + it('should handle undefined type input', () => { + fixture.componentRef.setInput('type', undefined); + fixture.detectChanges(); + + expect(component.type()).toBeUndefined(); + }); + + it('should handle null provider input', () => { + fixture.componentRef.setInput('provider', null); + fixture.detectChanges(); + + expect(component.provider()).toBeNull(); + }); + + it('should render type in template', () => { + const mockType = 'Preprint'; + fixture.componentRef.setInput('type', mockType); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + const typeElement = compiled.querySelector('[data-test-display-registry-type]'); + expect(typeElement).toBeTruthy(); + expect(typeElement.textContent.trim()).toBe(mockType); + }); + + it('should render provider name in template', () => { + fixture.componentRef.setInput('provider', mockProvider); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + const providerElement = compiled.querySelector('[data-test-display-registry-provider]'); + expect(providerElement).toBeTruthy(); + expect(providerElement.textContent.trim()).toBe(mockProvider.name); + }); + + it('should display empty string when type is empty', () => { + fixture.componentRef.setInput('type', ''); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + const typeElement = compiled.querySelector('[data-test-display-registry-type]'); + expect(typeElement.textContent.trim()).toBe(''); + }); + + it('should display empty string when provider is null', () => { + fixture.componentRef.setInput('provider', null); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + const providerElement = compiled.querySelector('[data-test-display-registry-provider]'); + expect(providerElement.textContent.trim()).toBe(''); + }); + + it('should display both type and provider when both are set', () => { + const mockType = 'Registered Report'; + fixture.componentRef.setInput('type', mockType); + fixture.componentRef.setInput('provider', mockProvider); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + const typeElement = compiled.querySelector('[data-test-display-registry-type]'); + const providerElement = compiled.querySelector('[data-test-display-registry-provider]'); + + expect(typeElement.textContent.trim()).toBe(mockType); + expect(providerElement.textContent.trim()).toBe(mockProvider.name); + }); + + it('should display translated labels', () => { + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + const headings = compiled.querySelectorAll('h2'); + expect(headings.length).toBe(2); + }); +}); diff --git a/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.ts b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.ts new file mode 100644 index 000000000..25c83a0f0 --- /dev/null +++ b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.ts @@ -0,0 +1,19 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Card } from 'primeng/card'; + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +import { RegistryProviderDetails } from '@osf/shared/models/provider/registry-provider.model'; + +@Component({ + selector: 'osf-metadata-registry-info', + imports: [Card, TranslatePipe], + templateUrl: './metadata-registry-info.component.html', + styleUrl: './metadata-registry-info.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MetadataRegistryInfoComponent { + type = input(''); + provider = input(); +} diff --git a/src/app/features/metadata/mappers/metadata.mapper.ts b/src/app/features/metadata/mappers/metadata.mapper.ts index 5c166574d..eb6044cb3 100644 --- a/src/app/features/metadata/mappers/metadata.mapper.ts +++ b/src/app/features/metadata/mappers/metadata.mapper.ts @@ -25,6 +25,7 @@ export class MetadataMapper { provider: response.embeds?.provider?.data.id, public: response.attributes.public, currentUserPermissions: response.attributes.current_user_permissions, + registrationSupplement: response.attributes.registration_supplement, }; } diff --git a/src/app/features/metadata/metadata.component.html b/src/app/features/metadata/metadata.component.html index 491ad60dd..f49bd7ff0 100644 --- a/src/app/features/metadata/metadata.component.html +++ b/src/app/features/metadata/metadata.component.html @@ -33,6 +33,10 @@ [readonly]="!hasWriteAccess()" /> + @if (isRegistrationType()) { + + } + { { selector: MetadataSelectors.getSubmitting, value: false }, { selector: MetadataSelectors.getCedarRecords, value: [] }, { selector: MetadataSelectors.getCedarTemplates, value: null }, + { selector: RegistrationProviderSelectors.getBrandedProvider, value: null }, ], }), ], diff --git a/src/app/features/metadata/metadata.component.ts b/src/app/features/metadata/metadata.component.ts index e20e097f7..85958743d 100644 --- a/src/app/features/metadata/metadata.component.ts +++ b/src/app/features/metadata/metadata.component.ts @@ -37,6 +37,7 @@ import { InstitutionsSelectors, UpdateResourceInstitutions, } from '@osf/shared/stores/institutions'; +import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; import { FetchChildrenSubjects, FetchSelectedSubjects, @@ -48,6 +49,7 @@ import { MetadataTabsModel } from '@shared/models/metadata-tabs.model'; import { SubjectModel } from '@shared/models/subject/subject.model'; import { MetadataCollectionsComponent } from './components/metadata-collections/metadata-collections.component'; +import { MetadataRegistryInfoComponent } from './components/metadata-registry-info/metadata-registry-info.component'; import { EditTitleDialogComponent } from './dialogs/edit-title-dialog/edit-title-dialog.component'; import { MetadataAffiliatedInstitutionsComponent, @@ -112,6 +114,7 @@ import { MetadataTitleComponent, MetadataRegistrationDoiComponent, MetadataCollectionsComponent, + MetadataRegistryInfoComponent, ], templateUrl: './metadata.component.html', styleUrl: './metadata.component.scss', @@ -150,6 +153,7 @@ export class MetadataComponent implements OnInit { affiliatedInstitutions = select(InstitutionsSelectors.getResourceInstitutions); areInstitutionsLoading = select(InstitutionsSelectors.areResourceInstitutionsLoading); areResourceInstitutionsSubmitting = select(InstitutionsSelectors.areResourceInstitutionsSubmitting); + registryProvider = select(RegistrationProviderSelectors.getBrandedProvider); projectSubmissions = select(CollectionsSelectors.getCurrentProjectSubmissions); isProjectSubmissionsLoading = select(CollectionsSelectors.getCurrentProjectSubmissionsLoading); diff --git a/src/app/features/metadata/models/metadata-json-api.model.ts b/src/app/features/metadata/models/metadata-json-api.model.ts index 11dfbd342..802777461 100644 --- a/src/app/features/metadata/models/metadata-json-api.model.ts +++ b/src/app/features/metadata/models/metadata-json-api.model.ts @@ -21,6 +21,7 @@ export interface MetadataAttributesJsonApi { category?: string; node_license?: LicenseRecordJsonApi; public?: boolean; + registration_supplement?: string; current_user_permissions: UserPermissions[]; } diff --git a/src/app/features/metadata/models/metadata.model.ts b/src/app/features/metadata/models/metadata.model.ts index 43c44bf84..b8ce8f5cb 100644 --- a/src/app/features/metadata/models/metadata.model.ts +++ b/src/app/features/metadata/models/metadata.model.ts @@ -24,6 +24,7 @@ export interface MetadataModel { }; public?: boolean; currentUserPermissions: UserPermissions[]; + registrationSupplement?: string; } export interface CustomItemMetadataRecord { diff --git a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.html b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.html index 2637d5b9b..c8f736206 100644 --- a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.html +++ b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.html @@ -42,6 +42,11 @@

{{ 'registry.overview.metadata.type' | translate }}

{{ resource.registrationSupplement }}

+
+

{{ 'registry.overview.metadata.registry' | translate }}

+

{{ registryProvider()?.name }}

+
+ @if (resource.associatedProjectId) {

{{ 'registry.overview.metadata.associatedProject' | translate }}

diff --git a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts index 697e0a1de..72dca0a1a 100644 --- a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts +++ b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts @@ -17,6 +17,7 @@ import { SubjectsListComponent } from '@osf/shared/components/subjects-list/subj import { TagsListComponent } from '@osf/shared/components/tags-list/tags-list.component'; import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; import { ContributorsSelectors, LoadMoreBibliographicContributors } from '@osf/shared/stores/contributors'; +import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; import { @@ -79,6 +80,7 @@ describe('RegistryOverviewMetadataComponent', () => { { selector: RegistrySelectors.isIdentifiersLoading, value: false }, { selector: RegistrySelectors.getInstitutions, value: [] }, { selector: RegistrySelectors.isInstitutionsLoading, value: false }, + { selector: RegistrationProviderSelectors.getBrandedProvider, value: null }, { selector: SubjectsSelectors.getSubjects, value: [] }, { selector: SubjectsSelectors.getSubjectsLoading, value: false }, { selector: ContributorsSelectors.getBibliographicContributors, value: [] }, diff --git a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts index c2c3b4686..693e8eebd 100644 --- a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts +++ b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts @@ -19,6 +19,7 @@ import { TagsListComponent } from '@osf/shared/components/tags-list/tags-list.co import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; import { ContributorsSelectors, LoadMoreBibliographicContributors } from '@osf/shared/stores/contributors'; +import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; import { @@ -54,6 +55,7 @@ export class RegistryOverviewMetadataComponent { private readonly router = inject(Router); readonly registry = select(RegistrySelectors.getRegistry); + readonly registryProvider = select(RegistrationProviderSelectors.getBrandedProvider); readonly isAnonymous = select(RegistrySelectors.isRegistryAnonymous); canEdit = select(RegistrySelectors.hasWriteAccess); diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 9ee6ffb5c..5a12c0109 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2645,6 +2645,7 @@ }, "metadata": { "type": "Registration Type", + "registry": "Registry", "registeredDate": "Date registered", "doi": "Registration DOI", "associatedProject": "Associated project" From bf56c47025fcaeede0ccf7e3b696d6af56942b79 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 30 Jan 2026 18:31:21 +0200 Subject: [PATCH 02/27] [ENG-10047] Display Affiliated Institution(s) on User Profile Page (#859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ticket: https://openscience.atlassian.net/browse/ENG-10047 - Feature flag: n/a ## Purpose User profile pages do not currently display a user’s affiliated institution(s), even when the user has active institutional affiliations set in OSF. This makes it difficult for others to understand a user’s institutional context and reduces the visibility of institutional participation on the platform. ## Summary of Changes Implement affiliated Institution(s) on User Profile Page showing --- .../profile-information.component.html | 19 +++++++++++++ .../profile-information.component.spec.ts | 27 +++++++++++++++++++ .../profile-information.component.ts | 5 ++++ .../features/profile/profile.component.html | 7 ++++- src/app/features/profile/profile.component.ts | 5 ++++ .../store/account-settings.actions.ts | 2 ++ .../store/account-settings.state.ts | 4 +-- .../add-project-form.component.spec.ts | 2 +- .../shared/services/institutions.service.ts | 4 +-- .../institutions/institutions.actions.ts | 1 + .../stores/institutions/institutions.state.ts | 4 +-- 11 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/app/features/profile/components/profile-information/profile-information.component.html b/src/app/features/profile/components/profile-information/profile-information.component.html index b4636126f..80ae98a4a 100644 --- a/src/app/features/profile/components/profile-information/profile-information.component.html +++ b/src/app/features/profile/components/profile-information/profile-information.component.html @@ -35,6 +35,25 @@

{{ currentUser()?.fullName }}

}
+
+ @for (institution of currentUserInstitutions(); track $index) { + + + + } +
+ @if (!isMedium() && showEdit()) {
{ it('should initialize with default inputs', () => { expect(component.currentUser()).toBeUndefined(); expect(component.showEdit()).toBe(false); + expect(component.currentUserInstitutions()).toBeUndefined(); }); it('should accept user input', () => { @@ -172,4 +175,28 @@ describe('ProfileInformationComponent', () => { component.toProfileSettings(); expect(component.editProfile.emit).toHaveBeenCalled(); }); + + it('should accept currentUserInstitutions input', () => { + const mockInstitutions: Institution[] = [MOCK_INSTITUTION]; + fixture.componentRef.setInput('currentUserInstitutions', mockInstitutions); + fixture.detectChanges(); + expect(component.currentUserInstitutions()).toEqual(mockInstitutions); + }); + + it('should not render institution logos when currentUserInstitutions is undefined', () => { + fixture.componentRef.setInput('currentUserInstitutions', undefined); + fixture.detectChanges(); + const logos = fixture.nativeElement.querySelectorAll('img.fit-contain'); + expect(logos.length).toBe(0); + }); + + it('should render institution logos when currentUserInstitutions is provided', () => { + const institutions: Institution[] = [MOCK_INSTITUTION]; + fixture.componentRef.setInput('currentUserInstitutions', institutions); + fixture.detectChanges(); + + const logos = fixture.nativeElement.querySelectorAll('img.fit-contain'); + expect(logos.length).toBe(institutions.length); + expect(logos[0].alt).toBe(institutions[0].name); + }); }); diff --git a/src/app/features/profile/components/profile-information/profile-information.component.ts b/src/app/features/profile/components/profile-information/profile-information.component.ts index 3304a8ce2..0740068b0 100644 --- a/src/app/features/profile/components/profile-information/profile-information.component.ts +++ b/src/app/features/profile/components/profile-information/profile-information.component.ts @@ -5,6 +5,7 @@ import { Button } from 'primeng/button'; import { DatePipe, NgOptimizedImage } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; +import { RouterLink } from '@angular/router'; import { EducationHistoryComponent } from '@osf/shared/components/education-history/education-history.component'; import { EmploymentHistoryComponent } from '@osf/shared/components/employment-history/employment-history.component'; @@ -12,6 +13,7 @@ import { SOCIAL_LINKS } from '@osf/shared/constants/social-links.const'; import { IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens'; import { UserModel } from '@osf/shared/models/user/user.models'; import { SortByDatePipe } from '@osf/shared/pipes/sort-by-date.pipe'; +import { Institution } from '@shared/models/institutions/institutions.models'; import { mapUserSocials } from '../../helpers'; @@ -25,6 +27,7 @@ import { mapUserSocials } from '../../helpers'; DatePipe, NgOptimizedImage, SortByDatePipe, + RouterLink, ], templateUrl: './profile-information.component.html', styleUrl: './profile-information.component.scss', @@ -32,6 +35,8 @@ import { mapUserSocials } from '../../helpers'; }) export class ProfileInformationComponent { currentUser = input(); + + currentUserInstitutions = input(); showEdit = input(false); editProfile = output(); diff --git a/src/app/features/profile/profile.component.html b/src/app/features/profile/profile.component.html index 4176e33fc..958d22e05 100644 --- a/src/app/features/profile/profile.component.html +++ b/src/app/features/profile/profile.component.html @@ -13,7 +13,12 @@ } - +
@if (defaultSearchFiltersInitialized()) { diff --git a/src/app/features/profile/profile.component.ts b/src/app/features/profile/profile.component.ts index fb7c186a8..86e1afa43 100644 --- a/src/app/features/profile/profile.component.ts +++ b/src/app/features/profile/profile.component.ts @@ -25,6 +25,7 @@ import { SEARCH_TAB_OPTIONS } from '@osf/shared/constants/search-tab-options.con import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { UserModel } from '@osf/shared/models/user/user.models'; import { SetDefaultFilterValue } from '@osf/shared/stores/global-search'; +import { FetchUserInstitutions, InstitutionsSelectors } from '@shared/stores/institutions'; import { ProfileInformationComponent } from './components'; import { FetchUserProfile, ProfileSelectors, SetUserProfile } from './store'; @@ -46,11 +47,13 @@ export class ProfileComponent implements OnInit, OnDestroy { fetchUserProfile: FetchUserProfile, setDefaultFilterValue: SetDefaultFilterValue, setUserProfile: SetUserProfile, + fetchUserInstitutions: FetchUserInstitutions, }); loggedInUser = select(UserSelectors.getCurrentUser); userProfile = select(ProfileSelectors.getUserProfile); isUserLoading = select(ProfileSelectors.isUserProfileLoading); + institutions = select(InstitutionsSelectors.getUserInstitutions); resourceTabOptions = SEARCH_TAB_OPTIONS.filter((x) => x.value !== ResourceType.Agent); @@ -67,6 +70,8 @@ export class ProfileComponent implements OnInit, OnDestroy { } else if (currentUser) { this.setupMyProfile(currentUser); } + + this.actions.fetchUserInstitutions(userId || currentUser?.id); } ngOnDestroy(): void { diff --git a/src/app/features/settings/account-settings/store/account-settings.actions.ts b/src/app/features/settings/account-settings/store/account-settings.actions.ts index db53fa534..e4ba12de7 100644 --- a/src/app/features/settings/account-settings/store/account-settings.actions.ts +++ b/src/app/features/settings/account-settings/store/account-settings.actions.ts @@ -24,6 +24,8 @@ export class DeleteExternalIdentity { export class GetUserInstitutions { static readonly type = '[AccountSettings] Get User Institutions'; + + constructor(public userId = 'me') {} } export class DeleteUserInstitution { diff --git a/src/app/features/settings/account-settings/store/account-settings.state.ts b/src/app/features/settings/account-settings/store/account-settings.state.ts index eee615405..9382c7ee0 100644 --- a/src/app/features/settings/account-settings/store/account-settings.state.ts +++ b/src/app/features/settings/account-settings/store/account-settings.state.ts @@ -84,8 +84,8 @@ export class AccountSettingsState { } @Action(GetUserInstitutions) - getUserInstitutions(ctx: StateContext) { - return this.institutionsService.getUserInstitutions().pipe( + getUserInstitutions(ctx: StateContext, action: GetUserInstitutions) { + return this.institutionsService.getUserInstitutions(action.userId).pipe( tap((userInstitutions) => ctx.patchState({ userInstitutions })), catchError((error) => throwError(() => error)) ); diff --git a/src/app/shared/components/add-project-form/add-project-form.component.spec.ts b/src/app/shared/components/add-project-form/add-project-form.component.spec.ts index 54336feae..ee325c8f8 100644 --- a/src/app/shared/components/add-project-form/add-project-form.component.spec.ts +++ b/src/app/shared/components/add-project-form/add-project-form.component.spec.ts @@ -9,11 +9,11 @@ import { FormControl, FormGroup, Validators } from '@angular/forms'; import { UserSelectors } from '@core/store/user'; import { ProjectFormControls } from '@osf/shared/enums/create-project-form-controls.enum'; import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; -import { ProjectModel } from '@osf/shared/models/projects'; import { InstitutionsSelectors } from '@osf/shared/stores/institutions'; import { ProjectsSelectors } from '@osf/shared/stores/projects'; import { RegionsSelectors } from '@osf/shared/stores/regions'; import { ProjectForm } from '@shared/models/projects/create-project-form.model'; +import { ProjectModel } from '@shared/models/projects/projects.models'; import { AffiliatedInstitutionSelectComponent } from '../affiliated-institution-select/affiliated-institution-select.component'; import { ProjectSelectorComponent } from '../project-selector/project-selector.component'; diff --git a/src/app/shared/services/institutions.service.ts b/src/app/shared/services/institutions.service.ts index 9858f02f2..b90fd89ea 100644 --- a/src/app/shared/services/institutions.service.ts +++ b/src/app/shared/services/institutions.service.ts @@ -47,8 +47,8 @@ export class InstitutionsService { .pipe(map((response) => InstitutionsMapper.fromResponseWithMeta(response))); } - getUserInstitutions(): Observable { - const url = `${this.apiUrl}/users/me/institutions/`; + getUserInstitutions(userId: string): Observable { + const url = `${this.apiUrl}/users/${userId}/institutions/`; return this.jsonApiService .get(url) diff --git a/src/app/shared/stores/institutions/institutions.actions.ts b/src/app/shared/stores/institutions/institutions.actions.ts index 7645e7d6b..4e7790f79 100644 --- a/src/app/shared/stores/institutions/institutions.actions.ts +++ b/src/app/shared/stores/institutions/institutions.actions.ts @@ -3,6 +3,7 @@ import { Institution } from '@shared/models/institutions/institutions.models'; export class FetchUserInstitutions { static readonly type = '[Institutions] Fetch User Institutions'; + constructor(public userId = 'me') {} } export class FetchInstitutions { diff --git a/src/app/shared/stores/institutions/institutions.state.ts b/src/app/shared/stores/institutions/institutions.state.ts index 631f9ec56..757012656 100644 --- a/src/app/shared/stores/institutions/institutions.state.ts +++ b/src/app/shared/stores/institutions/institutions.state.ts @@ -25,10 +25,10 @@ export class InstitutionsState { private readonly institutionsService = inject(InstitutionsService); @Action(FetchUserInstitutions) - getUserInstitutions(ctx: StateContext) { + getUserInstitutions(ctx: StateContext, action: FetchUserInstitutions) { ctx.setState(patch({ userInstitutions: patch({ isLoading: true }) })); - return this.institutionsService.getUserInstitutions().pipe( + return this.institutionsService.getUserInstitutions(action.userId).pipe( tap((institutions) => { ctx.setState( patch({ From cd3155997d62a4e21cd1b581bcf523e608e33ce5 Mon Sep 17 00:00:00 2001 From: sh-andriy <105591819+sh-andriy@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:33:36 +0200 Subject: [PATCH 03/27] ENG-9720 | fix(addons): Fix GitLab pagination Load More button not showing (#847) --- .../storage-item-selector.component.spec.ts | 105 ++++++++++++++++++ .../storage-item-selector.component.ts | 4 +- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.spec.ts b/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.spec.ts index 2e3797ea6..f3961bd7d 100644 --- a/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.spec.ts +++ b/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.spec.ts @@ -2,9 +2,11 @@ import { MockComponents, MockProvider } from 'ng-mocks'; import { DialogService } from 'primeng/dynamicdialog'; +import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { OperationNames } from '@shared/enums/operation-names.enum'; +import { OperationInvocation } from '@shared/models/addons/operation-invocation.model'; import { AddonsSelectors } from '@shared/stores/addons'; import { GoogleFilePickerComponent } from '../../google-file-picker/google-file-picker.component'; @@ -20,9 +22,11 @@ describe('StorageItemSelectorComponent', () => { let component: StorageItemSelectorComponent; let fixture: ComponentFixture; let mockDialogService: ReturnType; + let mockOperationInvocation: WritableSignal; beforeEach(async () => { mockDialogService = DialogServiceMockBuilder.create().withOpenMock().build(); + mockOperationInvocation = signal(null); await TestBed.configureTestingModule({ imports: [ @@ -45,6 +49,10 @@ describe('StorageItemSelectorComponent', () => { selector: AddonsSelectors.getCreatedOrUpdatedConfiguredAddonSubmitting, value: false, }, + { + selector: AddonsSelectors.getOperationInvocation, + value: mockOperationInvocation, + }, ], }), MockProvider(DialogService, mockDialogService), @@ -53,6 +61,10 @@ describe('StorageItemSelectorComponent', () => { fixture = TestBed.createComponent(StorageItemSelectorComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('isGoogleFilePicker', false); + fixture.componentRef.setInput('accountName', 'test-account'); + fixture.componentRef.setInput('accountId', 'test-id'); + fixture.componentRef.setInput('operationInvocationResult', []); }); it('should create', () => { @@ -115,4 +127,97 @@ describe('StorageItemSelectorComponent', () => { expect(breadcrumbs[0].id).toBe(itemId); expect(breadcrumbs[0].label).toBe(itemName); }); + + describe('showLoadMoreButton', () => { + it('should return false when operationInvocation is null', () => { + mockOperationInvocation.set(null); + fixture.detectChanges(); + + expect(component.showLoadMoreButton()).toBe(false); + }); + + it('should return false when nextSampleCursor is not present', () => { + mockOperationInvocation.set({ + id: 'test-id', + type: 'operation-invocation', + invocationStatus: 'success', + operationName: 'list_root_items', + operationKwargs: {}, + operationResult: [], + itemCount: 10, + thisSampleCursor: 'cursor-1', + }); + fixture.detectChanges(); + + expect(component.showLoadMoreButton()).toBe(false); + }); + + it('should return true when nextSampleCursor differs from thisSampleCursor', () => { + mockOperationInvocation.set({ + id: 'test-id', + type: 'operation-invocation', + invocationStatus: 'success', + operationName: 'list_root_items', + operationKwargs: {}, + operationResult: [], + itemCount: 20, + thisSampleCursor: 'cursor-1', + nextSampleCursor: 'cursor-2', + }); + fixture.detectChanges(); + + expect(component.showLoadMoreButton()).toBe(true); + }); + + it('should return true for opaque/base64 cursors like GitLab uses', () => { + // GitLab uses base64-encoded cursors where lexicographic comparison doesn't work + mockOperationInvocation.set({ + id: 'test-id', + type: 'operation-invocation', + invocationStatus: 'success', + operationName: 'list_root_items', + operationKwargs: {}, + operationResult: [], + itemCount: 20, + thisSampleCursor: 'eyJpZCI6MTIzfQ==', + nextSampleCursor: 'eyJpZCI6MTQ1fQ==', + }); + fixture.detectChanges(); + + expect(component.showLoadMoreButton()).toBe(true); + }); + + it('should return false when nextSampleCursor equals thisSampleCursor', () => { + mockOperationInvocation.set({ + id: 'test-id', + type: 'operation-invocation', + invocationStatus: 'success', + operationName: 'list_root_items', + operationKwargs: {}, + operationResult: [], + itemCount: 10, + thisSampleCursor: 'cursor-1', + nextSampleCursor: 'cursor-1', + }); + fixture.detectChanges(); + + expect(component.showLoadMoreButton()).toBe(false); + }); + + it('should return true when nextSampleCursor exists but thisSampleCursor is undefined', () => { + mockOperationInvocation.set({ + id: 'test-id', + type: 'operation-invocation', + invocationStatus: 'success', + operationName: 'list_root_items', + operationKwargs: {}, + operationResult: [], + itemCount: 20, + nextSampleCursor: 'cursor-2', + }); + fixture.detectChanges(); + + expect(component.showLoadMoreButton()).toBe(true); + }); + }); }); diff --git a/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.ts b/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.ts index fc29d0ae8..3f8588d07 100644 --- a/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.ts +++ b/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.ts @@ -199,10 +199,10 @@ export class StorageItemSelectorComponent implements OnInit { readonly showLoadMoreButton = computed(() => { const invocation = this.operationInvocation(); - if (!invocation?.nextSampleCursor || !invocation?.thisSampleCursor) { + if (!invocation?.nextSampleCursor) { return false; } - return invocation.nextSampleCursor > invocation.thisSampleCursor; + return invocation.nextSampleCursor !== invocation.thisSampleCursor; }); handleCreateOperationInvocation( From 0113de1f113389f98149ad2c2f086da4f8cb43d2 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 30 Jan 2026 18:37:57 +0200 Subject: [PATCH 04/27] [ENG-9042] Each registries, preprints, and collections provider sets a default license in admin. (#796) - Ticket: https://openscience.atlassian.net/browse/ENG-9042 - Feature flag: n/a ## Purpose Each registries, preprints, and collections provider sets a default license in admin. ## Summary of Changes These should be preselected on all registration drafts on that provider, and the user can change them from there. All provider types need a serialized default license. --- .../preprints-metadata-step.component.html | 2 +- .../preprints-metadata-step.component.ts | 20 ++++++++++++- .../preprints/mappers/preprints.mapper.ts | 1 + .../models/preprint-json-api.models.ts | 1 + .../preprints/models/preprint.models.ts | 1 + .../preprint-stepper.state.ts | 1 - .../registries-license.component.html | 2 +- .../registries-license.component.ts | 29 +++++++++++++------ .../registration/registration.mapper.ts | 1 + .../registration/draft-registration.model.ts | 1 + .../registration-json-api.model.ts | 1 + 11 files changed, 47 insertions(+), 13 deletions(-) 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 77e60fe11..3d20423c3 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 @@ -16,7 +16,7 @@

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

(); nextClicked = output(); backClicked = output(); + defaultLicense = signal(undefined); + + constructor() { + effect(() => { + const licenses = this.licenses(); + const preprint = this.createdPreprint(); + + if (licenses.length && preprint && !preprint.licenseId && preprint.defaultLicenseId) { + const defaultLicense = licenses.find((license) => license.id === preprint?.defaultLicenseId); + if (defaultLicense) { + this.defaultLicense.set(defaultLicense.id); + if (!defaultLicense.requiredFields.length) { + this.actions.saveLicense(defaultLicense.id); + } + } + } + }); + } ngOnInit() { this.actions.fetchLicenses(); diff --git a/src/app/features/preprints/mappers/preprints.mapper.ts b/src/app/features/preprints/mappers/preprints.mapper.ts index 61dd10329..23f72d0be 100644 --- a/src/app/features/preprints/mappers/preprints.mapper.ts +++ b/src/app/features/preprints/mappers/preprints.mapper.ts @@ -83,6 +83,7 @@ export class PreprintsMapper { articleDoiLink: response.links.doi, embeddedLicense: null, providerId: response.relationships?.provider?.data?.id, + defaultLicenseId: response.attributes.default_license_id, }; } diff --git a/src/app/features/preprints/models/preprint-json-api.models.ts b/src/app/features/preprints/models/preprint-json-api.models.ts index 9761ef0fa..3fa417f48 100644 --- a/src/app/features/preprints/models/preprint-json-api.models.ts +++ b/src/app/features/preprints/models/preprint-json-api.models.ts @@ -37,6 +37,7 @@ export interface PreprintAttributesJsonApi { why_no_prereg: StringOrNull; prereg_links: string[]; prereg_link_info: PreregLinkInfo | null; + default_license_id: string; } export interface PreprintRelationshipsJsonApi { diff --git a/src/app/features/preprints/models/preprint.models.ts b/src/app/features/preprints/models/preprint.models.ts index 527c1a76d..e966e60ce 100644 --- a/src/app/features/preprints/models/preprint.models.ts +++ b/src/app/features/preprints/models/preprint.models.ts @@ -47,6 +47,7 @@ export interface PreprintModel { articleDoiLink?: string; identifiers?: IdentifierModel[]; providerId: string; + defaultLicenseId?: string; } export interface PreprintFilesLinks { 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 d915e5782..1e1eb3bd1 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 @@ -87,7 +87,6 @@ export class PreprintStepperState { if (action.payload.isPublished) { ctx.setState(patch({ hasBeenSubmitted: true })); } - ctx.setState(patch({ preprint: patch({ isSubmitting: false, data: preprint }) })); }), catchError((error) => handleSectionError(ctx, 'preprint', error)) diff --git a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.html b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.html index da782b1e3..0366f69b3 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.html +++ b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.html @@ -11,7 +11,7 @@

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

{ const licenses = this.licenses(); - const selectedLicense = untracked(() => this.selectedLicense()); + const selectedLicense = this.selectedLicense(); + const defaultLicenseId = this.draftRegistration()?.defaultLicenseId; - if (!licenses.length || !selectedLicense) { + if (!licenses.length) { return; } - if (!licenses.find((license) => license.id === selectedLicense.id)) { - this.control().patchValue({ - id: null, - }); - this.control().markAsTouched(); - this.control().updateValueAndValidity(); + if ( + defaultLicenseId && + (!selectedLicense?.id || !licenses.find((license) => license.id === selectedLicense?.id)) + ) { + const defaultLicense = licenses.find((license) => license.id === defaultLicenseId); + if (defaultLicense) { + this.control().patchValue({ + id: defaultLicense.id, + }); + this.control().markAsTouched(); + this.control().updateValueAndValidity(); + + if (!defaultLicense.requiredFields.length) { + this.actions.saveLicense(this.draftId, defaultLicense.id); + } + } } }); } diff --git a/src/app/shared/mappers/registration/registration.mapper.ts b/src/app/shared/mappers/registration/registration.mapper.ts index 43040ec85..41fbe6ef4 100644 --- a/src/app/shared/mappers/registration/registration.mapper.ts +++ b/src/app/shared/mappers/registration/registration.mapper.ts @@ -47,6 +47,7 @@ export class RegistrationMapper { }, providerId: response.relationships.provider?.data?.id || '', hasProject: !!response.attributes.has_project, + defaultLicenseId: response.attributes?.default_license_id, components: [], currentUserPermissions: response.attributes.current_user_permissions, }; diff --git a/src/app/shared/models/registration/draft-registration.model.ts b/src/app/shared/models/registration/draft-registration.model.ts index 4d0230e0d..4a18222ac 100644 --- a/src/app/shared/models/registration/draft-registration.model.ts +++ b/src/app/shared/models/registration/draft-registration.model.ts @@ -18,6 +18,7 @@ export interface DraftRegistrationModel { branchedFrom?: Partial; providerId: string; hasProject: boolean; + defaultLicenseId?: string; components: Partial[]; currentUserPermissions: UserPermissions[]; } diff --git a/src/app/shared/models/registration/registration-json-api.model.ts b/src/app/shared/models/registration/registration-json-api.model.ts index 1a6d64e12..1e38892af 100644 --- a/src/app/shared/models/registration/registration-json-api.model.ts +++ b/src/app/shared/models/registration/registration-json-api.model.ts @@ -39,6 +39,7 @@ export interface DraftRegistrationAttributesJsonApi { datetime_updated: string; description: string; has_project: boolean; + default_license_id?: string; node_license: LicenseRecordJsonApi; registration_metadata: Record; registration_responses: Record; From 4d85d516616778edf3ebba8faec4e2a77017c7fd Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 30 Jan 2026 18:40:56 +0200 Subject: [PATCH 05/27] [ENG-6719] Show Funder and Grant ID information on registries moderation cards (#855) - Ticket: https://openscience.atlassian.net/browse/ENG-6719 - Feature flag: n/a ## Purpose Some registries members use funder information to determine priority or relevance of submissions. They currently must drill down several layers within their moderation workflow to find this information. --- src/app/features/metadata/services/index.ts | 1 - .../features/metadata/store/metadata.state.ts | 2 +- ...egistry-pending-submissions.component.html | 2 +- .../registry-pending-submissions.component.ts | 7 ++ .../registry-submission-item.component.html | 9 ++- .../registry-submission-item.component.ts | 7 +- .../registry-submissions.component.html | 2 +- .../registry-submissions.component.ts | 7 ++ .../models/registry-moderation.model.ts | 3 + .../registry-moderation.actions.ts | 6 ++ .../registry-moderation.state.ts | 59 ++++++++++++++++- .../funder-awards-list.component.html | 24 +++++++ .../funder-awards-list.component.scss | 0 .../funder-awards-list.component.spec.ts | 66 +++++++++++++++++++ .../funder-awards-list.component.ts | 21 ++++++ .../services/metadata.service.ts | 19 +++--- src/assets/i18n/en.json | 1 + 17 files changed, 218 insertions(+), 18 deletions(-) delete mode 100644 src/app/features/metadata/services/index.ts create mode 100644 src/app/shared/funder-awards-list/funder-awards-list.component.html create mode 100644 src/app/shared/funder-awards-list/funder-awards-list.component.scss create mode 100644 src/app/shared/funder-awards-list/funder-awards-list.component.spec.ts create mode 100644 src/app/shared/funder-awards-list/funder-awards-list.component.ts rename src/app/{features/metadata => shared}/services/metadata.service.ts (97%) diff --git a/src/app/features/metadata/services/index.ts b/src/app/features/metadata/services/index.ts deleted file mode 100644 index 92c69e450..000000000 --- a/src/app/features/metadata/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './metadata.service'; diff --git a/src/app/features/metadata/store/metadata.state.ts b/src/app/features/metadata/store/metadata.state.ts index 245895fd9..af839233a 100644 --- a/src/app/features/metadata/store/metadata.state.ts +++ b/src/app/features/metadata/store/metadata.state.ts @@ -5,9 +5,9 @@ import { catchError, finalize, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; +import { MetadataService } from '@osf/shared/services/metadata.service'; import { CedarMetadataRecord, CedarMetadataRecordJsonApi, MetadataModel } from '../models'; -import { MetadataService } from '../services'; import { AddCedarMetadataRecordToState, diff --git a/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.html b/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.html index 77f120b97..7275ce5ce 100644 --- a/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.html +++ b/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.html @@ -39,7 +39,7 @@ [submission]="item" [status]="selectedReviewOption()" (selected)="navigateToRegistration(item)" - (loadContributors)="loadContributors(item)" + (loadAdditionalData)="loadAdditionalData(item)" (loadMoreContributors)="loadMoreContributors(item)" > diff --git a/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.ts b/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.ts index ac0f00e76..26b08f29c 100644 --- a/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.ts +++ b/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.ts @@ -26,6 +26,7 @@ import { RegistrySort, SubmissionReviewStatus } from '../../enums'; import { RegistryModeration } from '../../models'; import { GetRegistrySubmissionContributors, + GetRegistrySubmissionFunders, GetRegistrySubmissions, LoadMoreRegistrySubmissionContributors, RegistryModerationSelectors, @@ -63,6 +64,7 @@ export class RegistryPendingSubmissionsComponent implements OnInit { getRegistrySubmissions: GetRegistrySubmissions, getRegistrySubmissionContributors: GetRegistrySubmissionContributors, loadMoreRegistrySubmissionContributors: LoadMoreRegistrySubmissionContributors, + getRegistrySubmissionFunders: GetRegistrySubmissionFunders, }); readonly submissions = select(RegistryModerationSelectors.getRegistrySubmissions); @@ -129,6 +131,11 @@ export class RegistryPendingSubmissionsComponent implements OnInit { this.actions.loadMoreRegistrySubmissionContributors(item.id); } + loadAdditionalData(item: RegistryModeration) { + this.actions.getRegistrySubmissionContributors(item.id); + this.actions.getRegistrySubmissionFunders(item.id); + } + private getStatusFromQueryParams() { const queryParams = this.route.snapshot.queryParams; const statusValues = Object.values(SubmissionReviewStatus); diff --git a/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.html b/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.html index 006ce75fc..8bc4d4a6e 100644 --- a/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.html +++ b/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.html @@ -64,12 +64,19 @@

{{ submission().title }}

+
+ +
diff --git a/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.ts b/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.ts index 770db64d4..93b331b09 100644 --- a/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.ts +++ b/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.ts @@ -10,6 +10,7 @@ import { ContributorsListComponent } from '@osf/shared/components/contributors-l import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; import { DateAgoPipe } from '@osf/shared/pipes/date-ago.pipe'; +import { FunderAwardsListComponent } from '@shared/funder-awards-list/funder-awards-list.component'; import { REGISTRY_ACTION_LABEL, ReviewStatusIcon } from '../../constants'; import { ActionStatus, SubmissionReviewStatus } from '../../enums'; @@ -29,6 +30,7 @@ import { RegistryModeration } from '../../models'; AccordionHeader, AccordionContent, ContributorsListComponent, + FunderAwardsListComponent, ], templateUrl: './registry-submission-item.component.html', styleUrl: './registry-submission-item.component.scss', @@ -37,9 +39,8 @@ import { RegistryModeration } from '../../models'; export class RegistrySubmissionItemComponent { status = input.required(); submission = input.required(); - loadContributors = output(); loadMoreContributors = output(); - + loadAdditionalData = output(); selected = output(); readonly reviewStatusIcon = ReviewStatusIcon; @@ -67,6 +68,6 @@ export class RegistrySubmissionItemComponent { }); handleOpen() { - this.loadContributors.emit(); + this.loadAdditionalData.emit(); } } diff --git a/src/app/features/moderation/components/registry-submissions/registry-submissions.component.html b/src/app/features/moderation/components/registry-submissions/registry-submissions.component.html index 5066d15f7..73386a8f1 100644 --- a/src/app/features/moderation/components/registry-submissions/registry-submissions.component.html +++ b/src/app/features/moderation/components/registry-submissions/registry-submissions.component.html @@ -39,7 +39,7 @@ [submission]="item" [status]="selectedReviewOption()" (selected)="navigateToRegistration(item)" - (loadContributors)="loadContributors(item)" + (loadAdditionalData)="loadAdditionalData(item)" (loadMoreContributors)="loadMoreContributors(item)" > diff --git a/src/app/features/moderation/components/registry-submissions/registry-submissions.component.ts b/src/app/features/moderation/components/registry-submissions/registry-submissions.component.ts index e664daacc..3271d565f 100644 --- a/src/app/features/moderation/components/registry-submissions/registry-submissions.component.ts +++ b/src/app/features/moderation/components/registry-submissions/registry-submissions.component.ts @@ -26,6 +26,7 @@ import { RegistrySort, SubmissionReviewStatus } from '../../enums'; import { RegistryModeration } from '../../models'; import { GetRegistrySubmissionContributors, + GetRegistrySubmissionFunders, GetRegistrySubmissions, LoadMoreRegistrySubmissionContributors, RegistryModerationSelectors, @@ -63,6 +64,7 @@ export class RegistrySubmissionsComponent implements OnInit { getRegistrySubmissions: GetRegistrySubmissions, getRegistrySubmissionContributors: GetRegistrySubmissionContributors, loadMoreRegistrySubmissionContributors: LoadMoreRegistrySubmissionContributors, + getRegistrySubmissionFunders: GetRegistrySubmissionFunders, }); readonly submissions = select(RegistryModerationSelectors.getRegistrySubmissions); @@ -129,6 +131,11 @@ export class RegistrySubmissionsComponent implements OnInit { this.actions.loadMoreRegistrySubmissionContributors(item.id); } + loadAdditionalData(item: RegistryModeration) { + this.actions.getRegistrySubmissionContributors(item.id); + this.actions.getRegistrySubmissionFunders(item.id); + } + private getStatusFromQueryParams() { const queryParams = this.route.snapshot.queryParams; const statusValues = Object.values(SubmissionReviewStatus); diff --git a/src/app/features/moderation/models/registry-moderation.model.ts b/src/app/features/moderation/models/registry-moderation.model.ts index 2d59b2681..31da4f051 100644 --- a/src/app/features/moderation/models/registry-moderation.model.ts +++ b/src/app/features/moderation/models/registry-moderation.model.ts @@ -1,3 +1,4 @@ +import { Funder } from '@osf/features/metadata/models'; import { RegistrationReviewStates } from '@osf/shared/enums/registration-review-states.enum'; import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; import { ContributorModel } from '@shared/models/contributors/contributor.model'; @@ -18,4 +19,6 @@ export interface RegistryModeration { contributors?: ContributorModel[]; totalContributors?: number; contributorsPage?: number; + funders?: Funder[]; + fundersLoading?: boolean; } diff --git a/src/app/features/moderation/store/registry-moderation/registry-moderation.actions.ts b/src/app/features/moderation/store/registry-moderation/registry-moderation.actions.ts index 3e350142c..eafd2f856 100644 --- a/src/app/features/moderation/store/registry-moderation/registry-moderation.actions.ts +++ b/src/app/features/moderation/store/registry-moderation/registry-moderation.actions.ts @@ -27,3 +27,9 @@ export class LoadMoreRegistrySubmissionContributors { constructor(public registryId: string) {} } + +export class GetRegistrySubmissionFunders { + static readonly type = `${ACTION_SCOPE} Get Registry Submission Funders`; + + constructor(public registryId: string) {} +} diff --git a/src/app/features/moderation/store/registry-moderation/registry-moderation.state.ts b/src/app/features/moderation/store/registry-moderation/registry-moderation.state.ts index 52b068e89..5c4715b76 100644 --- a/src/app/features/moderation/store/registry-moderation/registry-moderation.state.ts +++ b/src/app/features/moderation/store/registry-moderation/registry-moderation.state.ts @@ -7,6 +7,7 @@ import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { PaginatedData } from '@osf/shared/models/paginated-data.model'; +import { MetadataService } from '@osf/shared/services/metadata.service'; import { DEFAULT_TABLE_PARAMS } from '@shared/constants/default-table-params.constants'; import { ResourceType } from '@shared/enums/resource-type.enum'; import { ContributorsService } from '@shared/services/contributors.service'; @@ -16,6 +17,7 @@ import { RegistryModerationService } from '../../services'; import { GetRegistrySubmissionContributors, + GetRegistrySubmissionFunders, GetRegistrySubmissions, LoadMoreRegistrySubmissionContributors, } from './registry-moderation.actions'; @@ -29,7 +31,7 @@ import { REGISTRY_MODERATION_STATE_DEFAULTS, RegistryModerationStateModel } from export class RegistryModerationState { private readonly registryModerationService = inject(RegistryModerationService); private readonly contributorsService = inject(ContributorsService); - + private readonly metadataService = inject(MetadataService); @Action(GetRegistrySubmissionContributors) getRegistrySubmissionContributors( ctx: StateContext, @@ -151,4 +153,59 @@ export class RegistryModerationState { catchError((error) => handleSectionError(ctx, 'submissions', error)) ); } + + @Action(GetRegistrySubmissionFunders) + getRegistrySubmissionFunders( + ctx: StateContext, + { registryId }: GetRegistrySubmissionFunders + ) { + const state = ctx.getState(); + const submission = state.submissions.data.find((s) => s.id === registryId); + + if (submission?.funders && submission.funders.length > 0) { + return; + } + + ctx.setState( + patch({ + submissions: patch({ + data: updateItem( + (submission) => submission.id === registryId, + patch({ fundersLoading: true }) + ), + }), + }) + ); + + return this.metadataService.getCustomItemMetadata(registryId).pipe( + tap((res) => { + ctx.setState( + patch({ + submissions: patch({ + data: updateItem( + (submission) => submission.id === registryId, + patch({ + funders: res.funders, + fundersLoading: false, + }) + ), + }), + }) + ); + }), + catchError((error) => { + ctx.setState( + patch({ + submissions: patch({ + data: updateItem( + (submission) => submission.id === registryId, + patch({ fundersLoading: false }) + ), + }), + }) + ); + return handleSectionError(ctx, 'submissions', error); + }) + ); + } } diff --git a/src/app/shared/funder-awards-list/funder-awards-list.component.html b/src/app/shared/funder-awards-list/funder-awards-list.component.html new file mode 100644 index 000000000..95fcdad0b --- /dev/null +++ b/src/app/shared/funder-awards-list/funder-awards-list.component.html @@ -0,0 +1,24 @@ +
+ @if (isLoading()) { +
+ +
+ } @else { + @if (funders().length) { +

{{ 'resourceCard.labels.funderAwards' | translate }}

+
+ @for (funder of funders(); track $index) { + +
{{ funder.funderName }}
+ +
{{ funder.awardNumber }}
+
+ } +
+ } + } +
diff --git a/src/app/shared/funder-awards-list/funder-awards-list.component.scss b/src/app/shared/funder-awards-list/funder-awards-list.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/funder-awards-list/funder-awards-list.component.spec.ts b/src/app/shared/funder-awards-list/funder-awards-list.component.spec.ts new file mode 100644 index 000000000..d066f8152 --- /dev/null +++ b/src/app/shared/funder-awards-list/funder-awards-list.component.spec.ts @@ -0,0 +1,66 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideRouter } from '@angular/router'; + +import { FunderAwardsListComponent } from './funder-awards-list.component'; + +import { MOCK_FUNDERS } from '@testing/mocks/funder.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('FunderAwardsListComponent', () => { + let component: FunderAwardsListComponent; + let fixture: ComponentFixture; + + const MOCK_REGISTRY_ID = 'test-registry-123'; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FunderAwardsListComponent, OSFTestingModule], + providers: [provideRouter([])], + }).compileComponents(); + + fixture = TestBed.createComponent(FunderAwardsListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not render the list or label if funders array is empty', () => { + fixture.componentRef.setInput('funders', []); + fixture.detectChanges(); + const label = fixture.debugElement.query(By.css('p')); + const links = fixture.debugElement.queryAll(By.css('a')); + expect(label).toBeNull(); + expect(links.length).toBe(0); + }); + + it('should render a list of funders when data is provided', () => { + fixture.componentRef.setInput('funders', MOCK_FUNDERS); + fixture.componentRef.setInput('registryId', MOCK_REGISTRY_ID); + fixture.detectChanges(); + const links = fixture.debugElement.queryAll(By.css('a')); + expect(links.length).toBe(2); + const firstItemText = links[0].nativeElement.textContent; + expect(firstItemText).toContain('National Science Foundation'); + expect(firstItemText).toContain('NSF-1234567'); + }); + + it('should generate the correct router link', () => { + fixture.componentRef.setInput('funders', MOCK_FUNDERS); + fixture.componentRef.setInput('registryId', MOCK_REGISTRY_ID); + fixture.detectChanges(); + const linkDebugEl = fixture.debugElement.query(By.css('a')); + const href = linkDebugEl.nativeElement.getAttribute('href'); + expect(href).toContain(`/${MOCK_REGISTRY_ID}/metadata/osf`); + }); + + it('should open links in a new tab', () => { + fixture.componentRef.setInput('funders', MOCK_FUNDERS); + fixture.detectChanges(); + const linkDebugEl = fixture.debugElement.query(By.css('a')); + expect(linkDebugEl.attributes['target']).toBe('_blank'); + }); +}); diff --git a/src/app/shared/funder-awards-list/funder-awards-list.component.ts b/src/app/shared/funder-awards-list/funder-awards-list.component.ts new file mode 100644 index 000000000..969bf1053 --- /dev/null +++ b/src/app/shared/funder-awards-list/funder-awards-list.component.ts @@ -0,0 +1,21 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Skeleton } from 'primeng/skeleton'; + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +import { Funder } from '@osf/features/metadata/models'; + +@Component({ + selector: 'osf-funder-awards-list', + imports: [RouterLink, TranslatePipe, Skeleton], + templateUrl: './funder-awards-list.component.html', + styleUrl: './funder-awards-list.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FunderAwardsListComponent { + funders = input([]); + registryId = input(null); + isLoading = input(false); +} diff --git a/src/app/features/metadata/services/metadata.service.ts b/src/app/shared/services/metadata.service.ts similarity index 97% rename from src/app/features/metadata/services/metadata.service.ts rename to src/app/shared/services/metadata.service.ts index 75ae0c86b..b74d82b64 100644 --- a/src/app/features/metadata/services/metadata.service.ts +++ b/src/app/shared/services/metadata.service.ts @@ -4,24 +4,25 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; -import { LicenseOptions } from '@osf/shared/models/license/license.model'; -import { BaseNodeAttributesJsonApi } from '@osf/shared/models/nodes/base-node-attributes-json-api.model'; -import { JsonApiService } from '@osf/shared/services/json-api.service'; - -import { CedarRecordsMapper, MetadataMapper } from '../mappers'; +import { CedarRecordsMapper, MetadataMapper } from '@osf/features/metadata/mappers'; import { CedarMetadataRecord, CedarMetadataRecordJsonApi, CedarMetadataTemplateJsonApi, CedarRecordDataBinding, + CrossRefFundersResponse, + CustomItemMetadataRecord, CustomMetadataJsonApi, CustomMetadataJsonApiResponse, MetadataJsonApi, MetadataJsonApiResponse, -} from '../models'; -import { CrossRefFundersResponse, CustomItemMetadataRecord, MetadataModel } from '../models/metadata.model'; + MetadataModel, +} from '@osf/features/metadata/models'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; +import { LicenseOptions } from '@osf/shared/models/license/license.model'; +import { BaseNodeAttributesJsonApi } from '@osf/shared/models/nodes/base-node-attributes-json-api.model'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; @Injectable({ providedIn: 'root', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 5a12c0109..a2fde1dab 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2780,6 +2780,7 @@ "withdrawn": "Withdrawn", "from": "From:", "funder": "Funder:", + "funderAwards": "Funder awards:", "resourceNature": "Resource type:", "dateCreated": "Date created", "dateModified": "Date modified", From 90213a3d30c0c55e36356d3dc4347f877cf7d536 Mon Sep 17 00:00:00 2001 From: nsemets Date: Fri, 30 Jan 2026 18:47:38 +0200 Subject: [PATCH 06/27] fix(ssr-routes): removed home route due to auth (#866) ## Purpose The logged-in user was not redirected from the home page to the dashboard because the required authentication data was missing. ## Summary of Changes 1. Removed home route from `app.server.route.ts`. --- src/app/app.routes.server.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index 0b3e928f9..77c193b59 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -21,10 +21,6 @@ export const serverRoutes: ServerRoute[] = [ path: 'forgotpassword', renderMode: RenderMode.Prerender, }, - { - path: '', - renderMode: RenderMode.Prerender, - }, { path: 'dashboard', renderMode: RenderMode.Client, From a2a9f3525daddd05aca50a592edd0ca7512e5176 Mon Sep 17 00:00:00 2001 From: nsemets Date: Mon, 2 Feb 2026 16:45:25 +0200 Subject: [PATCH 07/27] [ENG-10148] Fix frontend state-management bug causing stale facet results #870 - Ticket: [ENG-10148] - Feature flag: n/a ## Summary of Changes 1. Added loading message. --- .../components/generic-filter/generic-filter.component.html | 1 + .../components/generic-filter/generic-filter.component.ts | 6 +++++- src/assets/i18n/en.json | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/shared/components/generic-filter/generic-filter.component.html b/src/app/shared/components/generic-filter/generic-filter.component.html index 25b7844ad..71a47aaa9 100644 --- a/src/app/shared/components/generic-filter/generic-filter.component.html +++ b/src/app/shared/components/generic-filter/generic-filter.component.html @@ -20,6 +20,7 @@ [virtualScrollItemSize]="40" scrollHeight="200px" [autoOptionFocus]="false" + [emptyFilterMessage]="filterMessage() | translate" [loading]="isPaginationLoading() || isSearchLoading()" (onFilter)="onFilterChange($event)" (onChange)="onMultiChange($event)" diff --git a/src/app/shared/components/generic-filter/generic-filter.component.ts b/src/app/shared/components/generic-filter/generic-filter.component.ts index 1a5391a0c..041160d70 100644 --- a/src/app/shared/components/generic-filter/generic-filter.component.ts +++ b/src/app/shared/components/generic-filter/generic-filter.component.ts @@ -1,3 +1,5 @@ +import { TranslatePipe } from '@ngx-translate/core'; + import { MultiSelect, MultiSelectChangeEvent } from 'primeng/multiselect'; import { SelectLazyLoadEvent } from 'primeng/select'; @@ -23,7 +25,7 @@ import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.comp @Component({ selector: 'osf-generic-filter', - imports: [MultiSelect, FormsModule, LoadingSpinnerComponent], + imports: [MultiSelect, FormsModule, LoadingSpinnerComponent, TranslatePipe], templateUrl: './generic-filter.component.html', styleUrls: ['./generic-filter.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, @@ -73,6 +75,8 @@ export class GenericFilterComponent { selectedOptionValues = computed(() => this.selectedOptions().map((option) => option.value)); + filterMessage = computed(() => (this.isSearchLoading() ? 'common.search.loading' : 'common.search.noResultsFound')); + constructor() { effect(() => { const searchResults = this.searchResults(); diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index a2fde1dab..01ef03921 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -84,6 +84,7 @@ "search": { "title": "Search", "noResultsFound": "No results found.", + "loading": "Loading results", "tabs": { "all": "All", "preprints": "Preprints", From 91f8e0dccca35aa4ebcd941ff8ab50a85a7a1741 Mon Sep 17 00:00:00 2001 From: nsemets Date: Fri, 6 Feb 2026 18:55:12 +0200 Subject: [PATCH 08/27] [ENG-10251] Standardize model file naming convention (#874) - Ticket: [ENG-10251] - Feature flag: n/a ## Summary of Changes 1. Renamed all instances of `.models.ts` to `.model.ts`. 2. Updated all internal references and import paths in components, services, and pipes to reflect the new file names. 3. Updated Jest unit tests to ensure imports within `.spec.ts` files are pointing to the corrected model paths. 4. Verified that no duplicate model definitions exist under the old naming scheme. 5. Remove animations. --- .../core/animations/fade.in-out.animation.ts | 39 ------------------- .../cookie-consent-banner.component.spec.ts | 2 +- .../cookie-consent-banner.component.ts | 3 -- .../maintenance-banner.component.html | 5 +-- .../maintenance-banner.component.spec.ts | 7 ++-- .../maintenance-banner.component.ts | 5 --- src/app/core/services/user.service.ts | 2 +- src/app/core/store/user/user.actions.ts | 2 +- src/app/core/store/user/user.model.ts | 2 +- src/app/core/store/user/user.selectors.ts | 2 +- src/app/core/store/user/user.state.ts | 2 +- .../institutions-summary.component.ts | 2 +- .../store/institutions-admin.model.ts | 2 +- .../store/institutions-admin.selectors.ts | 2 +- .../store/institutions-admin.state.ts | 2 +- .../features/analytics/analytics.component.ts | 2 +- .../collection-metadata-step.component.ts | 2 +- .../project-metadata-step.component.ts | 2 +- .../select-project-step.component.ts | 2 +- ...tions-search-result-card.component.spec.ts | 2 +- ...ollections-search-result-card.component.ts | 2 +- ...s => collection-license-json-api.model.ts} | 0 .../services/project-metadata-form.service.ts | 2 +- .../add-to-collection.model.ts | 2 +- src/app/features/contributors/models/index.ts | 2 +- ...odels.ts => view-only-components.model.ts} | 0 .../pages/dashboard/dashboard.component.ts | 4 +- src/app/features/meetings/models/index.ts | 2 +- .../{meetings.models.ts => meetings.model.ts} | 0 ...adata-affiliated-institutions.component.ts | 2 +- ...metadata-collection-item.component.spec.ts | 2 +- .../metadata-collection-item.component.ts | 2 +- .../metadata-collections.component.ts | 2 +- ...ated-institutions-dialog.component.spec.ts | 2 +- ...ffiliated-institutions-dialog.component.ts | 2 +- .../metadata/models/metadata.model.ts | 2 +- ...llection-submission-item.component.spec.ts | 2 +- .../collection-submission-item.component.ts | 2 +- .../collection-submissions-list.component.ts | 2 +- ...ubmission-review-action-json-api.model.ts} | 0 src/app/features/moderation/models/index.ts | 2 +- .../moderation/services/moderators.service.ts | 2 +- .../collections-moderation.model.ts | 2 +- .../my-projects/my-projects.component.ts | 4 +- ...-affiliated-institutions.component.spec.ts | 2 +- ...rints-affiliated-institutions.component.ts | 2 +- src/app/features/preprints/models/index.ts | 20 +++++----- ...i.models.ts => preprint-json-api.model.ts} | 0 ...ts => preprint-licenses-json-api.model.ts} | 0 ...ts => preprint-provider-json-api.model.ts} | 2 +- ...r.models.ts => preprint-provider.model.ts} | 0 ...preprint-request-action-json-api.model.ts} | 0 ...ls.ts => preprint-request-action.model.ts} | 0 ....ts => preprint-request-json-api.model.ts} | 0 ...st.models.ts => preprint-request.model.ts} | 0 .../{preprint.models.ts => preprint.model.ts} | 0 ...odels.ts => submit-preprint-form.model.ts} | 0 .../preprints/services/preprints.service.ts | 2 +- .../profile-information.component.spec.ts | 4 +- .../profile-information.component.ts | 4 +- src/app/features/profile/profile.component.ts | 2 +- .../features/profile/store/profile.actions.ts | 2 +- .../features/profile/store/profile.model.ts | 2 +- .../profile/store/profile.selectors.ts | 2 +- .../add-component-dialog.component.ts | 2 +- .../files-widget/files-widget.component.ts | 2 +- .../link-resource-dialog.component.spec.ts | 2 +- .../link-resource-dialog.component.ts | 4 +- .../overview-collections.component.ts | 2 +- .../features/project/overview/models/index.ts | 2 +- ...ew.models.ts => project-overview.model.ts} | 0 .../services/project-overview.service.ts | 2 +- .../overview/store/project-overview.model.ts | 2 +- .../connect-configured-addon.component.ts | 4 +- ...ings-project-affiliation.component.spec.ts | 2 +- .../settings-project-affiliation.component.ts | 2 +- .../settings/models/node-details.model.ts | 2 +- .../project/settings/settings.component.ts | 2 +- ...stries-affiliated-institution.component.ts | 2 +- .../services/registry-overview.service.ts | 2 +- .../registry/store/registry/registry.model.ts | 2 +- .../store/registry/registry.selectors.ts | 2 +- .../affiliated-institutions.component.ts | 2 +- .../services/account-settings.service.ts | 2 +- .../store/account-settings.model.ts | 2 +- .../store/account-settings.selectors.ts | 2 +- .../citation-preview.component.ts | 2 +- .../components/name/name.component.ts | 2 +- .../helpers/name-comparison.helper.ts | 2 +- .../connect-addon/connect-addon.component.ts | 4 +- .../add-project-form.component.ts | 4 +- .../addon-setup-account-form.component.ts | 4 +- .../addon-terms/addon-terms.component.spec.ts | 2 +- .../addon-terms/addon-terms.component.ts | 2 +- .../storage-item-selector.component.ts | 2 +- ...iated-institution-select.component.spec.ts | 2 +- ...affiliated-institution-select.component.ts | 2 +- ...liated-institutions-view.component.spec.ts | 2 +- .../affiliated-institutions-view.component.ts | 2 +- .../bar-chart/bar-chart.component.ts | 2 +- .../doughnut-chart.component.ts | 2 +- .../components/license/license.component.ts | 2 +- .../line-chart/line-chart.component.spec.ts | 2 +- .../line-chart/line-chart.component.ts | 2 +- .../my-projects-table.component.spec.ts | 2 +- .../my-projects-table.component.ts | 2 +- .../pie-chart/pie-chart.component.spec.ts | 2 +- .../pie-chart/pie-chart.component.ts | 2 +- .../project-selector.component.ts | 2 +- src/app/shared/constants/addon-terms.const.ts | 2 +- .../helpers/search-total-count.helper.ts | 2 +- src/app/shared/mappers/addon.mapper.ts | 4 +- .../mappers/collections/collections.mapper.ts | 4 +- .../mappers/filters/filter-option.mapper.ts | 4 +- .../shared/mappers/filters/filters.mapper.ts | 2 +- .../institutions/institutions.mapper.ts | 2 +- src/app/shared/mappers/my-resources.mapper.ts | 2 +- .../mappers/projects/projects.mapper.ts | 4 +- .../shared/mappers/search/search.mapper.ts | 6 +-- src/app/shared/mappers/user/user.mapper.ts | 2 +- ...-api.models.ts => addon-json-api.model.ts} | 0 ....ts => addon-operations-json-api.model.ts} | 0 ...n-utils.models.ts => addon-utils.model.ts} | 0 ...ataset-input.ts => dataset-input.model.ts} | 0 ...odels.ts => collections-json-api.model.ts} | 0 ...ections.models.ts => collections.model.ts} | 2 +- .../institution-json-api.model.ts | 2 +- ...utions.models.ts => institutions.model.ts} | 0 ...e-form.models.ts => license-form.model.ts} | 0 ...s => my-resources-search-filters.model.ts} | 0 ...ources.models.ts => my-resources.model.ts} | 0 .../models/profile-settings-update.model.ts | 2 +- .../{projects.models.ts => projects.model.ts} | 0 .../registration/draft-registration.model.ts | 2 +- .../request-access/request-access.model.ts | 2 +- ...ls.ts => filter-options-json-api.model.ts} | 2 +- ...ts => index-card-search-json-api.model.ts} | 0 .../user/{user.models.ts => user.model.ts} | 0 src/app/shared/pipes/citation-format.pipe.ts | 2 +- .../services/addons/addon-form.service.ts | 6 +-- .../services/addons/addon-oauth.service.ts | 2 +- .../addon-operation-invocation.service.ts | 2 +- .../shared/services/addons/addons.service.ts | 4 +- src/app/shared/services/bookmarks.service.ts | 6 +-- .../shared/services/collections.service.ts | 4 +- .../shared/services/contributors.service.ts | 2 +- src/app/shared/services/files.service.ts | 2 +- .../shared/services/global-search.service.ts | 4 +- .../shared/services/institutions.service.ts | 2 +- .../shared/services/my-resources.service.ts | 4 +- src/app/shared/services/node-links.service.ts | 2 +- src/app/shared/services/projects.service.ts | 2 +- .../shared/stores/addons/addons.actions.ts | 4 +- src/app/shared/stores/addons/addons.models.ts | 2 +- .../shared/stores/addons/addons.selectors.ts | 2 +- .../stores/bookmarks/bookmarks.actions.ts | 2 +- .../stores/bookmarks/bookmarks.model.ts | 2 +- .../stores/collections/collections.model.ts | 2 +- .../institutions-search.model.ts | 2 +- .../institutions-search.state.ts | 2 +- .../institutions/institutions.actions.ts | 2 +- .../stores/institutions/institutions.model.ts | 2 +- .../my-resources/my-resources.actions.ts | 2 +- .../stores/my-resources/my-resources.model.ts | 2 +- .../my-resources/my-resources.selectors.ts | 2 +- .../stores/node-links/node-links.actions.ts | 2 +- .../stores/projects/projects.actions.ts | 2 +- .../shared/stores/projects/projects.model.ts | 2 +- .../collection-submissions.mock.ts | 2 +- src/testing/data/dashboard/dasboard.data.ts | 2 +- .../mocks/collections-submissions.mock.ts | 2 +- src/testing/mocks/data.mock.ts | 2 +- src/testing/mocks/my-resources.mock.ts | 2 +- src/testing/mocks/project-metadata.mock.ts | 2 +- src/testing/mocks/project.mock.ts | 2 +- src/testing/mocks/submission.mock.ts | 2 +- ...addon-operation-invocation.service.mock.ts | 2 +- 177 files changed, 184 insertions(+), 233 deletions(-) delete mode 100644 src/app/core/animations/fade.in-out.animation.ts rename src/app/features/collections/models/{collection-license-json-api.models.ts => collection-license-json-api.model.ts} (100%) rename src/app/features/contributors/models/{view-only-components.models.ts => view-only-components.model.ts} (100%) rename src/app/features/meetings/models/{meetings.models.ts => meetings.model.ts} (100%) rename src/app/features/moderation/models/{collection-submission-review-action-json.api.ts => collection-submission-review-action-json-api.model.ts} (100%) rename src/app/features/preprints/models/{preprint-json-api.models.ts => preprint-json-api.model.ts} (100%) rename src/app/features/preprints/models/{preprint-licenses-json-api.models.ts => preprint-licenses-json-api.model.ts} (100%) rename src/app/features/preprints/models/{preprint-provider-json-api.models.ts => preprint-provider-json-api.model.ts} (94%) rename src/app/features/preprints/models/{preprint-provider.models.ts => preprint-provider.model.ts} (100%) rename src/app/features/preprints/models/{preprint-request-action-json-api.models.ts => preprint-request-action-json-api.model.ts} (100%) rename src/app/features/preprints/models/{preprint-request-action.models.ts => preprint-request-action.model.ts} (100%) rename src/app/features/preprints/models/{preprint-request-json-api.models.ts => preprint-request-json-api.model.ts} (100%) rename src/app/features/preprints/models/{preprint-request.models.ts => preprint-request.model.ts} (100%) rename src/app/features/preprints/models/{preprint.models.ts => preprint.model.ts} (100%) rename src/app/features/preprints/models/{submit-preprint-form.models.ts => submit-preprint-form.model.ts} (100%) rename src/app/features/project/overview/models/{project-overview.models.ts => project-overview.model.ts} (100%) rename src/app/shared/models/addons/{addon-json-api.models.ts => addon-json-api.model.ts} (100%) rename src/app/shared/models/addons/{addon-operations-json-api.models.ts => addon-operations-json-api.model.ts} (100%) rename src/app/shared/models/addons/{addon-utils.models.ts => addon-utils.model.ts} (100%) rename src/app/shared/models/charts/{dataset-input.ts => dataset-input.model.ts} (100%) rename src/app/shared/models/collections/{collections-json-api.models.ts => collections-json-api.model.ts} (100%) rename src/app/shared/models/collections/{collections.models.ts => collections.model.ts} (97%) rename src/app/shared/models/institutions/{institutions.models.ts => institutions.model.ts} (100%) rename src/app/shared/models/license/{license-form.models.ts => license-form.model.ts} (100%) rename src/app/shared/models/my-resources/{my-resources-search-filters.models.ts => my-resources-search-filters.model.ts} (100%) rename src/app/shared/models/my-resources/{my-resources.models.ts => my-resources.model.ts} (100%) rename src/app/shared/models/projects/{projects.models.ts => projects.model.ts} (100%) rename src/app/shared/models/search/{filter-options-json-api.models.ts => filter-options-json-api.model.ts} (98%) rename src/app/shared/models/search/{index-card-search-json-api.models.ts => index-card-search-json-api.model.ts} (100%) rename src/app/shared/models/user/{user.models.ts => user.model.ts} (100%) diff --git a/src/app/core/animations/fade.in-out.animation.ts b/src/app/core/animations/fade.in-out.animation.ts deleted file mode 100644 index 7befb072b..000000000 --- a/src/app/core/animations/fade.in-out.animation.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { animate, style, transition, trigger } from '@angular/animations'; - -/** - * Angular animation trigger for fading elements in and out. - * - * This trigger can be used with Angular structural directives like `*ngIf` or `@if` - * to smoothly animate the appearance and disappearance of components or elements. - * - * ## Usage: - * - * In the component decorator: - * ```ts - * @Component({ - * selector: 'my-component', - * templateUrl: './my.component.html', - * animations: [fadeInOut] - * }) - * export class MyComponent {} - * ``` - * - * In the template: - * ```html - * @if (show) { - *
- * Fades in and out! - *
- * } - * ``` - * - * ## Transitions: - * - **:enter** — Fades in from opacity `0` to `1` over `200ms`. - * - **:leave** — Fades out from opacity `1` to `0` over `200ms`. - * - * @returns An Angular `AnimationTriggerMetadata` object used for component animations. - */ -export const fadeInOutAnimation = trigger('fadeInOut', [ - transition(':enter', [style({ opacity: 0 }), animate('200ms', style({ opacity: 1 }))]), - transition(':leave', [animate('200ms', style({ opacity: 0 }))]), -]); diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.spec.ts b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.spec.ts index c40290217..e6eefb1a9 100644 --- a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.spec.ts +++ b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.spec.ts @@ -20,7 +20,7 @@ describe('Component: Cookie Consent Banner', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [OSFTestingModule, CookieConsentBannerComponent, MockComponent(IconComponent)], + imports: [CookieConsentBannerComponent, OSFTestingModule, MockComponent(IconComponent)], providers: [{ provide: CookieService, useValue: cookieServiceMock }], }); diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts index 30f039af9..c593da853 100644 --- a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts +++ b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts @@ -7,7 +7,6 @@ import { Message } from 'primeng/message'; import { isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, PLATFORM_ID, signal } from '@angular/core'; -import { fadeInOutAnimation } from '@core/animations/fade.in-out.animation'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; /** @@ -15,7 +14,6 @@ import { IconComponent } from '@osf/shared/components/icon/icon.component'; * * - Uses `ngx-cookie-service` to persist acceptance across sessions. * - Automatically hides the banner if consent is already recorded. - * - Animates in/out using the `fadeInOutAnimation`. * - Supports translation via `TranslatePipe`. */ @Component({ @@ -23,7 +21,6 @@ import { IconComponent } from '@osf/shared/components/icon/icon.component'; templateUrl: './cookie-consent-banner.component.html', styleUrls: ['./cookie-consent-banner.component.scss'], imports: [Button, TranslatePipe, IconComponent, Message], - animations: [fadeInOutAnimation], changeDetection: ChangeDetectionStrategy.OnPush, }) export class CookieConsentBannerComponent { diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html index a936ebefc..9dd9ed582 100644 --- a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html @@ -1,13 +1,12 @@ @if (maintenance() && !dismissed()) { } diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts index e617a1333..80d8d59b1 100644 --- a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts @@ -1,16 +1,15 @@ import { CookieService } from 'ngx-cookie-service'; -import { MessageModule } from 'primeng/message'; - import { of } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MaintenanceBannerComponent } from './maintenance-banner.component'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + describe('Component: Maintenance Banner', () => { let fixture: ComponentFixture; let httpClient: { get: jest.Mock }; @@ -25,7 +24,7 @@ describe('Component: Maintenance Banner', () => { httpClient = { get: jest.fn() } as any; await TestBed.configureTestingModule({ - imports: [MaintenanceBannerComponent, NoopAnimationsModule, MessageModule], + imports: [MaintenanceBannerComponent, OSFTestingModule], providers: [ { provide: CookieService, useValue: cookieService }, { provide: HttpClient, useValue: httpClient }, diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts index 05b269412..71a328e52 100644 --- a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts @@ -5,8 +5,6 @@ import { MessageModule } from 'primeng/message'; import { CommonModule, isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, OnInit, PLATFORM_ID, signal } from '@angular/core'; -import { fadeInOutAnimation } from '@core/animations/fade.in-out.animation'; - import { MaintenanceModel } from '../models/maintenance.model'; import { MaintenanceService } from '../services/maintenance.service'; @@ -17,8 +15,6 @@ import { MaintenanceService } from '../services/maintenance.service'; * the banner. If not, it queries the maintenance status from the server and displays * the maintenance message if one is active. * - * The component supports animation via `fadeInOutAnimation` and is optimized with `OnPush` change detection. - * * @example * ```html * @@ -29,7 +25,6 @@ import { MaintenanceService } from '../services/maintenance.service'; imports: [CommonModule, MessageModule], templateUrl: './maintenance-banner.component.html', styleUrls: ['./maintenance-banner.component.scss'], - animations: [fadeInOutAnimation], changeDetection: ChangeDetectionStrategy.OnPush, }) export class MaintenanceBannerComponent implements OnInit { diff --git a/src/app/core/services/user.service.ts b/src/app/core/services/user.service.ts index 8de41701d..3506c67cc 100644 --- a/src/app/core/services/user.service.ts +++ b/src/app/core/services/user.service.ts @@ -5,9 +5,9 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { ProfileSettingsKey } from '@osf/shared/enums/profile-settings-key.enum'; import { UserMapper } from '@osf/shared/mappers/user'; +import { UserData, UserModel } from '@osf/shared/models/user/user.model'; import { JsonApiService } from '@osf/shared/services/json-api.service'; import { ProfileSettingsUpdate } from '@shared/models/profile-settings-update.model'; -import { UserData, UserModel } from '@shared/models/user/user.models'; import { UserAcceptedTermsOfServiceJsonApi, UserDataJsonApi, diff --git a/src/app/core/store/user/user.actions.ts b/src/app/core/store/user/user.actions.ts index c645288df..9219d8847 100644 --- a/src/app/core/store/user/user.actions.ts +++ b/src/app/core/store/user/user.actions.ts @@ -1,7 +1,7 @@ import { Education } from '@osf/shared/models/user/education.model'; import { Employment } from '@osf/shared/models/user/employment.model'; import { SocialModel } from '@osf/shared/models/user/social.model'; -import { UserModel } from '@osf/shared/models/user/user.models'; +import { UserModel } from '@osf/shared/models/user/user.model'; export class GetCurrentUser { static readonly type = '[User] Get Current User'; diff --git a/src/app/core/store/user/user.model.ts b/src/app/core/store/user/user.model.ts index 35a18a34b..e006d52c2 100644 --- a/src/app/core/store/user/user.model.ts +++ b/src/app/core/store/user/user.model.ts @@ -1,5 +1,5 @@ import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; -import { UserModel } from '@osf/shared/models/user/user.models'; +import { UserModel } from '@osf/shared/models/user/user.model'; export interface UserStateModel { currentUser: AsyncStateModel; diff --git a/src/app/core/store/user/user.selectors.ts b/src/app/core/store/user/user.selectors.ts index 7b42ca0ad..311d3eec1 100644 --- a/src/app/core/store/user/user.selectors.ts +++ b/src/app/core/store/user/user.selectors.ts @@ -3,7 +3,7 @@ import { Selector } from '@ngxs/store'; import { Education } from '@osf/shared/models/user/education.model'; import { Employment } from '@osf/shared/models/user/employment.model'; import { SocialModel } from '@osf/shared/models/user/social.model'; -import { UserModel } from '@osf/shared/models/user/user.models'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { UserStateModel } from './user.model'; import { UserState } from './user.state'; diff --git a/src/app/core/store/user/user.state.ts b/src/app/core/store/user/user.state.ts index a5bdb2e88..c3b65d803 100644 --- a/src/app/core/store/user/user.state.ts +++ b/src/app/core/store/user/user.state.ts @@ -9,8 +9,8 @@ import { UserService } from '@core/services/user.service'; import { ProfileSettingsKey } from '@osf/shared/enums/profile-settings-key.enum'; import { removeNullable } from '@osf/shared/helpers/remove-nullable.helper'; import { UserMapper } from '@osf/shared/mappers/user'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { SocialModel } from '@shared/models/user/social.model'; -import { UserModel } from '@shared/models/user/user.models'; import { AcceptTermsOfServiceByUser, diff --git a/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.ts b/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.ts index 500b29f19..03be73487 100644 --- a/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.ts +++ b/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.ts @@ -9,7 +9,7 @@ import { ActivatedRoute } from '@angular/router'; import { BarChartComponent } from '@osf/shared/components/bar-chart/bar-chart.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { StatisticCardComponent } from '@osf/shared/components/statistic-card/statistic-card.component'; -import { DatasetInput } from '@osf/shared/models/charts/dataset-input'; +import { DatasetInput } from '@osf/shared/models/charts/dataset-input.model'; import { SelectOption } from '@osf/shared/models/select-option.model'; import { DoughnutChartComponent } from '@shared/components/doughnut-chart/doughnut-chart.component'; diff --git a/src/app/features/admin-institutions/store/institutions-admin.model.ts b/src/app/features/admin-institutions/store/institutions-admin.model.ts index d62a6d0eb..239078b99 100644 --- a/src/app/features/admin-institutions/store/institutions-admin.model.ts +++ b/src/app/features/admin-institutions/store/institutions-admin.model.ts @@ -1,4 +1,4 @@ -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; diff --git a/src/app/features/admin-institutions/store/institutions-admin.selectors.ts b/src/app/features/admin-institutions/store/institutions-admin.selectors.ts index 4211deb98..721901e4d 100644 --- a/src/app/features/admin-institutions/store/institutions-admin.selectors.ts +++ b/src/app/features/admin-institutions/store/institutions-admin.selectors.ts @@ -1,6 +1,6 @@ import { Selector } from '@ngxs/store'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { InstitutionDepartment, InstitutionSearchFilter, InstitutionSummaryMetrics, InstitutionUser } from '../models'; diff --git a/src/app/features/admin-institutions/store/institutions-admin.state.ts b/src/app/features/admin-institutions/store/institutions-admin.state.ts index 6e2d720cb..8285669a1 100644 --- a/src/app/features/admin-institutions/store/institutions-admin.state.ts +++ b/src/app/features/admin-institutions/store/institutions-admin.state.ts @@ -6,7 +6,7 @@ import { catchError, tap, throwError } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { InstitutionsService } from '@osf/shared/services/institutions.service'; import { InstitutionsAdminService } from '../services/institutions-admin.service'; diff --git a/src/app/features/analytics/analytics.component.ts b/src/app/features/analytics/analytics.component.ts index a24a9d66d..5eba9475b 100644 --- a/src/app/features/analytics/analytics.component.ts +++ b/src/app/features/analytics/analytics.component.ts @@ -30,7 +30,7 @@ import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-l import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; import { replaceBadEncodedChars } from '@osf/shared/helpers/format-bad-encoding.helper'; import { Primitive } from '@osf/shared/helpers/types.helper'; -import { DatasetInput } from '@osf/shared/models/charts/dataset-input'; +import { DatasetInput } from '@osf/shared/models/charts/dataset-input.model'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { AnalyticsKpiComponent } from './components'; diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts index 8b17c2989..acb6a1d0b 100644 --- a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts @@ -14,7 +14,7 @@ import { collectionFilterTypes } from '@osf/features/collections/constants'; import { AddToCollectionSteps, CollectionFilterType } from '@osf/features/collections/enums'; import { CollectionFilterEntry } from '@osf/features/collections/models/collection-filter-entry.model'; import { AddToCollectionSelectors } from '@osf/features/collections/store/add-to-collection'; -import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.models'; +import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model'; import { CollectionsSelectors, GetCollectionDetails } from '@osf/shared/stores/collections'; @Component({ diff --git a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts index 95913df5c..2fd2b0b4c 100644 --- a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts @@ -40,7 +40,7 @@ import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/tr import { InputLimits } from '@osf/shared/constants/input-limits.const'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { LicenseModel } from '@osf/shared/models/license/license.model'; -import { ProjectModel } from '@osf/shared/models/projects/projects.models'; +import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { InterpolatePipe } from '@osf/shared/pipes/interpolate.pipe'; import { ToastService } from '@osf/shared/services/toast.service'; import { GetAllContributors } from '@osf/shared/stores/contributors'; diff --git a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts index 7c610f347..7658ab614 100644 --- a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts @@ -9,8 +9,8 @@ import { ChangeDetectionStrategy, Component, computed, input, output, signal } f import { AddToCollectionSteps } from '@osf/features/collections/enums'; import { ProjectSelectorComponent } from '@osf/shared/components/project-selector/project-selector.component'; +import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { SetSelectedProject } from '@osf/shared/stores/projects'; -import { ProjectModel } from '@shared/models/projects/projects.models'; import { CollectionsSelectors, GetUserCollectionSubmissions } from '@shared/stores/collections'; import { ProjectsSelectors } from '@shared/stores/projects/projects.selectors'; diff --git a/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.spec.ts b/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.spec.ts index f4738728c..96895956c 100644 --- a/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.spec.ts +++ b/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.spec.ts @@ -4,7 +4,7 @@ import { ComponentRef } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; -import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.models'; +import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model'; import { CollectionsSearchResultCardComponent } from './collections-search-result-card.component'; diff --git a/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.ts b/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.ts index 08299c88f..2a7b91117 100644 --- a/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.ts +++ b/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.ts @@ -5,7 +5,7 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co import { collectionFilterNames } from '@osf/features/collections/constants'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; -import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.models'; +import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model'; @Component({ selector: 'osf-collections-search-result-card', diff --git a/src/app/features/collections/models/collection-license-json-api.models.ts b/src/app/features/collections/models/collection-license-json-api.model.ts similarity index 100% rename from src/app/features/collections/models/collection-license-json-api.models.ts rename to src/app/features/collections/models/collection-license-json-api.model.ts diff --git a/src/app/features/collections/services/project-metadata-form.service.ts b/src/app/features/collections/services/project-metadata-form.service.ts index f0204c614..84a563259 100644 --- a/src/app/features/collections/services/project-metadata-form.service.ts +++ b/src/app/features/collections/services/project-metadata-form.service.ts @@ -3,9 +3,9 @@ import { FormControl, FormGroup, Validators } from '@angular/forms'; import { ProjectMetadataFormControls } from '@osf/features/collections/enums'; import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; +import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { LicenseModel } from '@shared/models/license/license.model'; import { ProjectMetadataUpdatePayload } from '@shared/models/project-metadata-update-payload.model'; -import { ProjectModel } from '@shared/models/projects/projects.models'; import { ProjectMetadataForm } from '../models/project-metadata-form.model'; diff --git a/src/app/features/collections/store/add-to-collection/add-to-collection.model.ts b/src/app/features/collections/store/add-to-collection/add-to-collection.model.ts index 04ad27492..ba47d319d 100644 --- a/src/app/features/collections/store/add-to-collection/add-to-collection.model.ts +++ b/src/app/features/collections/store/add-to-collection/add-to-collection.model.ts @@ -1,4 +1,4 @@ -import { CollectionProjectSubmission } from '@osf/shared/models/collections/collections.models'; +import { CollectionProjectSubmission } from '@osf/shared/models/collections/collections.model'; import { LicenseModel } from '@shared/models/license/license.model'; import { AsyncStateModel } from '@shared/models/store/async-state.model'; diff --git a/src/app/features/contributors/models/index.ts b/src/app/features/contributors/models/index.ts index 83d6f898d..62aef551e 100644 --- a/src/app/features/contributors/models/index.ts +++ b/src/app/features/contributors/models/index.ts @@ -1,2 +1,2 @@ export * from './resource-info.model'; -export * from './view-only-components.models'; +export * from './view-only-components.model'; diff --git a/src/app/features/contributors/models/view-only-components.models.ts b/src/app/features/contributors/models/view-only-components.model.ts similarity index 100% rename from src/app/features/contributors/models/view-only-components.models.ts rename to src/app/features/contributors/models/view-only-components.model.ts diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts index 17522201b..f9fa9eb5c 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.ts @@ -23,11 +23,11 @@ import { SearchInputComponent } from '@osf/shared/components/search-input/search import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; import { SortOrder } from '@osf/shared/enums/sort-order.enum'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model'; +import { MyResourcesSearchFilters } from '@osf/shared/models/my-resources/my-resources-search-filters.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ProjectRedirectDialogService } from '@osf/shared/services/project-redirect-dialog.service'; import { ClearMyResources, GetMyProjects, MyResourcesSelectors } from '@osf/shared/stores/my-resources'; -import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models'; -import { MyResourcesSearchFilters } from '@shared/models/my-resources/my-resources-search-filters.models'; import { TableParameters } from '@shared/models/table-parameters.model'; @Component({ diff --git a/src/app/features/meetings/models/index.ts b/src/app/features/meetings/models/index.ts index 4ff56dcc5..3f2965a75 100644 --- a/src/app/features/meetings/models/index.ts +++ b/src/app/features/meetings/models/index.ts @@ -1,4 +1,4 @@ export * from './meeting-feature-card.model'; -export * from './meetings.models'; +export * from './meetings.model'; export * from './meetings-json-api.model'; export * from './partner-organization.model'; diff --git a/src/app/features/meetings/models/meetings.models.ts b/src/app/features/meetings/models/meetings.model.ts similarity index 100% rename from src/app/features/meetings/models/meetings.models.ts rename to src/app/features/meetings/models/meetings.model.ts diff --git a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts index 36f21d30f..bcf1badf8 100644 --- a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts +++ b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts @@ -6,7 +6,7 @@ import { Card } from 'primeng/card'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; @Component({ selector: 'osf-metadata-affiliated-institutions', diff --git a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts index af5c251b3..65616f04e 100644 --- a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts +++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts @@ -3,7 +3,7 @@ import { MockComponents } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; -import { CollectionSubmission } from '@osf/shared/models/collections/collections.models'; +import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; import { MetadataCollectionItemComponent } from './metadata-collection-item.component'; diff --git a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts index e8ee18b6f..1c023afd9 100644 --- a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts +++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts @@ -8,7 +8,7 @@ import { RouterLink } from '@angular/router'; import { collectionFilterNames } from '@osf/features/collections/constants'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; -import { CollectionSubmission } from '@osf/shared/models/collections/collections.models'; +import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; import { KeyValueModel } from '@osf/shared/models/common/key-value.model'; import { CollectionStatusSeverityPipe } from '@osf/shared/pipes/collection-status-severity.pipe'; diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts index daa67530d..affc90e98 100644 --- a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts +++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts @@ -5,7 +5,7 @@ import { Skeleton } from 'primeng/skeleton'; import { ChangeDetectionStrategy, Component, input } from '@angular/core'; -import { CollectionSubmission } from '@osf/shared/models/collections/collections.models'; +import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; import { MetadataCollectionItemComponent } from '../metadata-collection-item/metadata-collection-item.component'; diff --git a/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.spec.ts b/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.spec.ts index 2378c870a..aa55a264f 100644 --- a/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.spec.ts +++ b/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.spec.ts @@ -5,8 +5,8 @@ import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { InstitutionsSelectors } from '@osf/shared/stores/institutions'; -import { Institution } from '@shared/models/institutions/institutions.models'; import { AffiliatedInstitutionsDialogComponent } from './affiliated-institutions-dialog.component'; diff --git a/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.ts b/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.ts index 079629c43..b05fa2e11 100644 --- a/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.ts +++ b/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.ts @@ -9,7 +9,7 @@ import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@ang import { ReactiveFormsModule } from '@angular/forms'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { FetchUserInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions'; @Component({ diff --git a/src/app/features/metadata/models/metadata.model.ts b/src/app/features/metadata/models/metadata.model.ts index b8ce8f5cb..675ad53fe 100644 --- a/src/app/features/metadata/models/metadata.model.ts +++ b/src/app/features/metadata/models/metadata.model.ts @@ -1,6 +1,6 @@ +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { UserPermissions } from '@shared/enums/user-permissions.enum'; import { IdentifierModel } from '@shared/models/identifiers/identifier.model'; -import { Institution } from '@shared/models/institutions/institutions.models'; import { LicenseModel } from '@shared/models/license/license.model'; export interface MetadataModel { diff --git a/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts b/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts index fd0b7ef0f..81c1c24db 100644 --- a/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts +++ b/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts @@ -4,8 +4,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; +import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model'; import { CollectionsSelectors } from '@osf/shared/stores/collections'; -import { CollectionSubmissionWithGuid } from '@shared/models/collections/collections.models'; import { DateAgoPipe } from '@shared/pipes/date-ago.pipe'; import { SubmissionReviewStatus } from '../../enums'; diff --git a/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.ts b/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.ts index 0f5d0a3ae..a1d475ac1 100644 --- a/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.ts +++ b/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.ts @@ -12,7 +12,7 @@ import { collectionFilterNames } from '@osf/features/collections/constants'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; -import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.models'; +import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model'; import { DateAgoPipe } from '@osf/shared/pipes/date-ago.pipe'; import { CollectionsSelectors } from '@osf/shared/stores/collections'; diff --git a/src/app/features/moderation/components/collection-submissions-list/collection-submissions-list.component.ts b/src/app/features/moderation/components/collection-submissions-list/collection-submissions-list.component.ts index e87f597fd..b98c947df 100644 --- a/src/app/features/moderation/components/collection-submissions-list/collection-submissions-list.component.ts +++ b/src/app/features/moderation/components/collection-submissions-list/collection-submissions-list.component.ts @@ -8,7 +8,7 @@ import { GetCollectionSubmissionContributors, LoadMoreCollectionSubmissionContributors, } from '@osf/features/moderation/store/collections-moderation'; -import { CollectionSubmissionWithGuid } from '@shared/models/collections/collections.models'; +import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model'; import { CollectionsModerationSelectors } from '../../store/collections-moderation'; import { CollectionSubmissionItemComponent } from '../collection-submission-item/collection-submission-item.component'; diff --git a/src/app/features/moderation/models/collection-submission-review-action-json.api.ts b/src/app/features/moderation/models/collection-submission-review-action-json-api.model.ts similarity index 100% rename from src/app/features/moderation/models/collection-submission-review-action-json.api.ts rename to src/app/features/moderation/models/collection-submission-review-action-json-api.model.ts diff --git a/src/app/features/moderation/models/index.ts b/src/app/features/moderation/models/index.ts index 302a37ead..7d32ef4a3 100644 --- a/src/app/features/moderation/models/index.ts +++ b/src/app/features/moderation/models/index.ts @@ -1,5 +1,5 @@ export * from './collection-submission-review-action.model'; -export * from './collection-submission-review-action-json.api'; +export * from './collection-submission-review-action-json-api.model'; export * from './invite-moderator-form.model'; export * from './moderator.model'; export * from './moderator-add.model'; diff --git a/src/app/features/moderation/services/moderators.service.ts b/src/app/features/moderation/services/moderators.service.ts index dfb86fca8..3a184913f 100644 --- a/src/app/features/moderation/services/moderators.service.ts +++ b/src/app/features/moderation/services/moderators.service.ts @@ -8,7 +8,7 @@ import { parseSearchTotalCount } from '@osf/shared/helpers/search-total-count.he import { MapResources } from '@osf/shared/mappers/search'; import { JsonApiResponse } from '@osf/shared/models/common/json-api.model'; import { PaginatedData } from '@osf/shared/models/paginated-data.model'; -import { IndexCardSearchResponseJsonApi } from '@osf/shared/models/search/index-card-search-json-api.models'; +import { IndexCardSearchResponseJsonApi } from '@osf/shared/models/search/index-card-search-json-api.model'; import { SearchUserDataModel } from '@osf/shared/models/user/search-user-data.model'; import { JsonApiService } from '@osf/shared/services/json-api.service'; import { StringOrNull } from '@shared/helpers/types.helper'; diff --git a/src/app/features/moderation/store/collections-moderation/collections-moderation.model.ts b/src/app/features/moderation/store/collections-moderation/collections-moderation.model.ts index b685d281e..daa7cb0af 100644 --- a/src/app/features/moderation/store/collections-moderation/collections-moderation.model.ts +++ b/src/app/features/moderation/store/collections-moderation/collections-moderation.model.ts @@ -1,5 +1,5 @@ import { CollectionSubmissionReviewAction } from '@osf/features/moderation/models'; -import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.models'; +import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index 688499d53..8e499b175 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -32,6 +32,8 @@ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; import { SortOrder } from '@osf/shared/enums/sort-order.enum'; import { IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model'; +import { MyResourcesSearchFilters } from '@osf/shared/models/my-resources/my-resources-search-filters.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ProjectRedirectDialogService } from '@osf/shared/services/project-redirect-dialog.service'; import { BookmarksSelectors, GetAllMyBookmarks, GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; @@ -42,8 +44,6 @@ import { GetMyRegistrations, MyResourcesSelectors, } from '@osf/shared/stores/my-resources'; -import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models'; -import { MyResourcesSearchFilters } from '@shared/models/my-resources/my-resources-search-filters.models'; import { QueryParams } from '@shared/models/query-params.model'; import { TableParameters } from '@shared/models/table-parameters.model'; 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 8bb817170..68cff07db 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 @@ -6,7 +6,7 @@ import { ReviewsState } from '@osf/features/preprints/enums'; import { PreprintProviderDetails } from '@osf/features/preprints/models'; import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; -import { Institution } from '@shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { InstitutionsSelectors } from '@shared/stores/institutions'; import { PreprintsAffiliatedInstitutionsComponent } from './preprints-affiliated-institutions.component'; 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 f1a315ce1..fc8444c93 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 @@ -11,7 +11,7 @@ import { PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/ 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.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { FetchResourceInstitutions, FetchUserInstitutions, diff --git a/src/app/features/preprints/models/index.ts b/src/app/features/preprints/models/index.ts index 9131b75cb..5c556d995 100644 --- a/src/app/features/preprints/models/index.ts +++ b/src/app/features/preprints/models/index.ts @@ -1,10 +1,10 @@ -export * from './preprint.models'; -export * from './preprint-json-api.models'; -export * from './preprint-licenses-json-api.models'; -export * from './preprint-provider.models'; -export * from './preprint-provider-json-api.models'; -export * from './preprint-request.models'; -export * from './preprint-request-action.models'; -export * from './preprint-request-action-json-api.models'; -export * from './preprint-request-json-api.models'; -export * from './submit-preprint-form.models'; +export * from './preprint.model'; +export * from './preprint-json-api.model'; +export * from './preprint-licenses-json-api.model'; +export * from './preprint-provider.model'; +export * from './preprint-provider-json-api.model'; +export * from './preprint-request.model'; +export * from './preprint-request-action.model'; +export * from './preprint-request-action-json-api.model'; +export * from './preprint-request-json-api.model'; +export * from './submit-preprint-form.model'; diff --git a/src/app/features/preprints/models/preprint-json-api.models.ts b/src/app/features/preprints/models/preprint-json-api.model.ts similarity index 100% rename from src/app/features/preprints/models/preprint-json-api.models.ts rename to src/app/features/preprints/models/preprint-json-api.model.ts diff --git a/src/app/features/preprints/models/preprint-licenses-json-api.models.ts b/src/app/features/preprints/models/preprint-licenses-json-api.model.ts similarity index 100% rename from src/app/features/preprints/models/preprint-licenses-json-api.models.ts rename to src/app/features/preprints/models/preprint-licenses-json-api.model.ts diff --git a/src/app/features/preprints/models/preprint-provider-json-api.models.ts b/src/app/features/preprints/models/preprint-provider-json-api.model.ts similarity index 94% rename from src/app/features/preprints/models/preprint-provider-json-api.models.ts rename to src/app/features/preprints/models/preprint-provider-json-api.model.ts index 0dffb8a70..e71cc5ee2 100644 --- a/src/app/features/preprints/models/preprint-provider-json-api.models.ts +++ b/src/app/features/preprints/models/preprint-provider-json-api.model.ts @@ -4,7 +4,7 @@ import { BrandDataJsonApi } from '@osf/shared/models/brand/brand.json-api.model' import { ProviderReviewsWorkflow } from '../enums'; -import { PreprintWord } from './preprint-provider.models'; +import { PreprintWord } from './preprint-provider.model'; export interface PreprintProviderDetailsJsonApi { id: string; diff --git a/src/app/features/preprints/models/preprint-provider.models.ts b/src/app/features/preprints/models/preprint-provider.model.ts similarity index 100% rename from src/app/features/preprints/models/preprint-provider.models.ts rename to src/app/features/preprints/models/preprint-provider.model.ts diff --git a/src/app/features/preprints/models/preprint-request-action-json-api.models.ts b/src/app/features/preprints/models/preprint-request-action-json-api.model.ts similarity index 100% rename from src/app/features/preprints/models/preprint-request-action-json-api.models.ts rename to src/app/features/preprints/models/preprint-request-action-json-api.model.ts diff --git a/src/app/features/preprints/models/preprint-request-action.models.ts b/src/app/features/preprints/models/preprint-request-action.model.ts similarity index 100% rename from src/app/features/preprints/models/preprint-request-action.models.ts rename to src/app/features/preprints/models/preprint-request-action.model.ts diff --git a/src/app/features/preprints/models/preprint-request-json-api.models.ts b/src/app/features/preprints/models/preprint-request-json-api.model.ts similarity index 100% rename from src/app/features/preprints/models/preprint-request-json-api.models.ts rename to src/app/features/preprints/models/preprint-request-json-api.model.ts diff --git a/src/app/features/preprints/models/preprint-request.models.ts b/src/app/features/preprints/models/preprint-request.model.ts similarity index 100% rename from src/app/features/preprints/models/preprint-request.models.ts rename to src/app/features/preprints/models/preprint-request.model.ts diff --git a/src/app/features/preprints/models/preprint.models.ts b/src/app/features/preprints/models/preprint.model.ts similarity index 100% rename from src/app/features/preprints/models/preprint.models.ts rename to src/app/features/preprints/models/preprint.model.ts diff --git a/src/app/features/preprints/models/submit-preprint-form.models.ts b/src/app/features/preprints/models/submit-preprint-form.model.ts similarity index 100% rename from src/app/features/preprints/models/submit-preprint-form.models.ts rename to src/app/features/preprints/models/submit-preprint-form.model.ts diff --git a/src/app/features/preprints/services/preprints.service.ts b/src/app/features/preprints/services/preprints.service.ts index 15e11e7bc..6b9b780e1 100644 --- a/src/app/features/preprints/services/preprints.service.ts +++ b/src/app/features/preprints/services/preprints.service.ts @@ -6,7 +6,7 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { RegistryModerationMapper } from '@osf/features/moderation/mappers'; import { ReviewActionsResponseJsonApi } from '@osf/features/moderation/models'; import { PreprintRequestActionsMapper } from '@osf/features/preprints/mappers/preprint-request-actions.mapper'; -import { PreprintRequestAction } from '@osf/features/preprints/models/preprint-request-action.models'; +import { PreprintRequestAction } from '@osf/features/preprints/models/preprint-request-action.model'; import { searchPreferencesToJsonApiQueryParams } from '@osf/shared/helpers/search-pref-to-json-api-query-params.helper'; import { StringOrNull } from '@osf/shared/helpers/types.helper'; import { diff --git a/src/app/features/profile/components/profile-information/profile-information.component.spec.ts b/src/app/features/profile/components/profile-information/profile-information.component.spec.ts index 52adcbdb4..b209e62d7 100644 --- a/src/app/features/profile/components/profile-information/profile-information.component.spec.ts +++ b/src/app/features/profile/components/profile-information/profile-information.component.spec.ts @@ -7,9 +7,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { EducationHistoryComponent } from '@osf/shared/components/education-history/education-history.component'; import { EmploymentHistoryComponent } from '@osf/shared/components/employment-history/employment-history.component'; import { IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens'; -import { Institution } from '@shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { SocialModel } from '@shared/models/user/social.model'; -import { UserModel } from '@shared/models/user/user.models'; import { ProfileInformationComponent } from './profile-information.component'; diff --git a/src/app/features/profile/components/profile-information/profile-information.component.ts b/src/app/features/profile/components/profile-information/profile-information.component.ts index 0740068b0..da555cac9 100644 --- a/src/app/features/profile/components/profile-information/profile-information.component.ts +++ b/src/app/features/profile/components/profile-information/profile-information.component.ts @@ -11,9 +11,9 @@ import { EducationHistoryComponent } from '@osf/shared/components/education-hist import { EmploymentHistoryComponent } from '@osf/shared/components/employment-history/employment-history.component'; import { SOCIAL_LINKS } from '@osf/shared/constants/social-links.const'; import { IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens'; -import { UserModel } from '@osf/shared/models/user/user.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { SortByDatePipe } from '@osf/shared/pipes/sort-by-date.pipe'; -import { Institution } from '@shared/models/institutions/institutions.models'; import { mapUserSocials } from '../../helpers'; diff --git a/src/app/features/profile/profile.component.ts b/src/app/features/profile/profile.component.ts index 86e1afa43..907ca38d7 100644 --- a/src/app/features/profile/profile.component.ts +++ b/src/app/features/profile/profile.component.ts @@ -23,7 +23,7 @@ import { GlobalSearchComponent } from '@osf/shared/components/global-search/glob import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SEARCH_TAB_OPTIONS } from '@osf/shared/constants/search-tab-options.const'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { UserModel } from '@osf/shared/models/user/user.models'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { SetDefaultFilterValue } from '@osf/shared/stores/global-search'; import { FetchUserInstitutions, InstitutionsSelectors } from '@shared/stores/institutions'; diff --git a/src/app/features/profile/store/profile.actions.ts b/src/app/features/profile/store/profile.actions.ts index 61269ae9e..edcdf0d64 100644 --- a/src/app/features/profile/store/profile.actions.ts +++ b/src/app/features/profile/store/profile.actions.ts @@ -1,4 +1,4 @@ -import { UserModel } from '@osf/shared/models/user/user.models'; +import { UserModel } from '@osf/shared/models/user/user.model'; export class FetchUserProfile { static readonly type = '[Profile] Fetch User Profile'; diff --git a/src/app/features/profile/store/profile.model.ts b/src/app/features/profile/store/profile.model.ts index 87d4feee1..3d11d531d 100644 --- a/src/app/features/profile/store/profile.model.ts +++ b/src/app/features/profile/store/profile.model.ts @@ -1,5 +1,5 @@ import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; -import { UserModel } from '@osf/shared/models/user/user.models'; +import { UserModel } from '@osf/shared/models/user/user.model'; export interface ProfileStateModel { userProfile: AsyncStateModel; diff --git a/src/app/features/profile/store/profile.selectors.ts b/src/app/features/profile/store/profile.selectors.ts index 48869e8c3..db39632b9 100644 --- a/src/app/features/profile/store/profile.selectors.ts +++ b/src/app/features/profile/store/profile.selectors.ts @@ -1,6 +1,6 @@ import { Selector } from '@ngxs/store'; -import { UserModel } from '@osf/shared/models/user/user.models'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { ProfileStateModel } from './profile.model'; import { ProfileState } from '.'; diff --git a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts index 85fc28603..ea91f887f 100644 --- a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts +++ b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts @@ -17,11 +17,11 @@ import { UserSelectors } from '@core/store/user'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; import { ComponentFormControls } from '@osf/shared/enums/create-component-form-controls.enum'; import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { ToastService } from '@osf/shared/services/toast.service'; import { FetchUserInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions'; import { FetchRegions, RegionsSelectors } from '@osf/shared/stores/regions'; import { ComponentForm } from '@shared/models/create-component-form.model'; -import { Institution } from '@shared/models/institutions/institutions.models'; import { CreateComponent, GetComponents, ProjectOverviewSelectors } from '../../store'; diff --git a/src/app/features/project/overview/components/files-widget/files-widget.component.ts b/src/app/features/project/overview/components/files-widget/files-widget.component.ts index c907027ac..535b01a43 100644 --- a/src/app/features/project/overview/components/files-widget/files-widget.component.ts +++ b/src/app/features/project/overview/components/files-widget/files-widget.component.ts @@ -38,7 +38,7 @@ import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { FileLabelModel } from '@osf/shared/models/files/file-label.model'; import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; -import { ProjectModel } from '@osf/shared/models/projects/projects.models'; +import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { SelectOption } from '@osf/shared/models/select-option.model'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts index f94d9f4c9..b74d844cf 100644 --- a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts @@ -9,9 +9,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; import { ResourceSearchMode } from '@osf/shared/enums/resource-search-mode.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model'; import { MyResourcesSelectors } from '@osf/shared/stores/my-resources'; import { NodeLinksSelectors } from '@osf/shared/stores/node-links'; -import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models'; import { ProjectOverviewSelectors } from '../../store'; diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts index fe5542a89..279a2bc55 100644 --- a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts +++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts @@ -29,10 +29,10 @@ import { SearchInputComponent } from '@osf/shared/components/search-input/search import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; import { ResourceSearchMode } from '@osf/shared/enums/resource-search-mode.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model'; +import { MyResourcesSearchFilters } from '@osf/shared/models/my-resources/my-resources-search-filters.model'; import { GetMyProjects, GetMyRegistrations, MyResourcesSelectors } from '@osf/shared/stores/my-resources'; import { CreateNodeLink, DeleteNodeLink, NodeLinksSelectors } from '@osf/shared/stores/node-links'; -import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models'; -import { MyResourcesSearchFilters } from '@shared/models/my-resources/my-resources-search-filters.models'; import { TableParameters } from '@shared/models/table-parameters.model'; import { ProjectOverviewSelectors } from '../../store'; diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts b/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts index 328898474..168a3530b 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts @@ -10,7 +10,7 @@ import { RouterLink } from '@angular/router'; import { collectionFilterNames } from '@osf/features/collections/constants'; import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive'; -import { CollectionSubmission } from '@osf/shared/models/collections/collections.models'; +import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; import { KeyValueModel } from '@osf/shared/models/common/key-value.model'; import { CollectionStatusSeverityPipe } from '@osf/shared/pipes/collection-status-severity.pipe'; diff --git a/src/app/features/project/overview/models/index.ts b/src/app/features/project/overview/models/index.ts index ffa3996aa..b2f55ace8 100644 --- a/src/app/features/project/overview/models/index.ts +++ b/src/app/features/project/overview/models/index.ts @@ -1,4 +1,4 @@ export * from './addon-tree-item.model'; export * from './formatted-citation-item.model'; export * from './privacy-status.model'; -export * from './project-overview.models'; +export * from './project-overview.model'; diff --git a/src/app/features/project/overview/models/project-overview.models.ts b/src/app/features/project/overview/models/project-overview.model.ts similarity index 100% rename from src/app/features/project/overview/models/project-overview.models.ts rename to src/app/features/project/overview/models/project-overview.model.ts diff --git a/src/app/features/project/overview/services/project-overview.service.ts b/src/app/features/project/overview/services/project-overview.service.ts index f923aa0a1..7133193be 100644 --- a/src/app/features/project/overview/services/project-overview.service.ts +++ b/src/app/features/project/overview/services/project-overview.service.ts @@ -15,6 +15,7 @@ import { NodeStorageMapper } from '@osf/shared/mappers/nodes/node-storage.mapper import { JsonApiResponse } from '@osf/shared/models/common/json-api.model'; import { IdentifiersResponseJsonApi } from '@osf/shared/models/identifiers/identifier-json-api.model'; import { InstitutionsJsonApiResponse } from '@osf/shared/models/institutions/institution-json-api.model'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { LicenseResponseJsonApi } from '@osf/shared/models/license/licenses-json-api.model'; import { BaseNodeModel, NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { BaseNodeDataJsonApi } from '@osf/shared/models/nodes/base-node-data-json-api.model'; @@ -26,7 +27,6 @@ import { NodeResponseJsonApi, NodesResponseJsonApi } from '@osf/shared/models/no import { PaginatedData } from '@osf/shared/models/paginated-data.model'; import { JsonApiService } from '@osf/shared/services/json-api.service'; import { IdentifierModel } from '@shared/models/identifiers/identifier.model'; -import { Institution } from '@shared/models/institutions/institutions.models'; import { LicenseModel } from '@shared/models/license/license.model'; import { ProjectOverviewMapper } from '../mappers'; diff --git a/src/app/features/project/overview/store/project-overview.model.ts b/src/app/features/project/overview/store/project-overview.model.ts index 8675ce272..51abff1ba 100644 --- a/src/app/features/project/overview/store/project-overview.model.ts +++ b/src/app/features/project/overview/store/project-overview.model.ts @@ -1,10 +1,10 @@ +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { BaseNodeModel, NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { NodePreprintModel } from '@osf/shared/models/nodes/node-preprint.model'; import { NodeStorageModel } from '@osf/shared/models/nodes/node-storage.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; import { IdentifierModel } from '@shared/models/identifiers/identifier.model'; -import { Institution } from '@shared/models/institutions/institutions.models'; import { LicenseModel } from '@shared/models/license/license.model'; import { ProjectOverviewModel } from '../models'; diff --git a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts index fa9eaa3cb..2ba414289 100644 --- a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts +++ b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts @@ -25,8 +25,8 @@ import { OperationNames } from '@osf/shared/enums/operation-names.enum'; import { ProjectAddonsStepperValue } from '@osf/shared/enums/profile-addons-stepper.enum'; import { getAddonTypeString } from '@osf/shared/helpers/addon-type.helper'; import { AddonModel } from '@osf/shared/models/addons/addon.model'; -import { AuthorizedAddonRequestJsonApi } from '@osf/shared/models/addons/addon-json-api.models'; -import { AddonTerm } from '@osf/shared/models/addons/addon-utils.models'; +import { AuthorizedAddonRequestJsonApi } from '@osf/shared/models/addons/addon-json-api.model'; +import { AddonTerm } from '@osf/shared/models/addons/addon-utils.model'; import { AuthorizedAccountModel } from '@osf/shared/models/addons/authorized-account.model'; import { AddonFormService } from '@osf/shared/services/addons/addon-form.service'; import { AddonOAuthService } from '@osf/shared/services/addons/addon-oauth.service'; diff --git a/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.spec.ts b/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.spec.ts index 6e12a2741..f13917010 100644 --- a/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.spec.ts +++ b/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { InstitutionsSelectors } from '@osf/shared/stores/institutions'; -import { Institution } from '@shared/models/institutions/institutions.models'; import { SettingsProjectAffiliationComponent } from './settings-project-affiliation.component'; diff --git a/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.ts b/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.ts index de8f86c66..112511600 100644 --- a/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.ts +++ b/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.ts @@ -8,7 +8,7 @@ import { Card } from 'primeng/card'; import { NgOptimizedImage } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, input, OnInit, output } from '@angular/core'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { FetchUserInstitutions, InstitutionsSelectors } from '@shared/stores/institutions'; @Component({ diff --git a/src/app/features/project/settings/models/node-details.model.ts b/src/app/features/project/settings/models/node-details.model.ts index 483791011..2f3e8bc24 100644 --- a/src/app/features/project/settings/models/node-details.model.ts +++ b/src/app/features/project/settings/models/node-details.model.ts @@ -1,6 +1,6 @@ import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { IdNameModel } from '@shared/models/common/id-name.model'; -import { Institution } from '@shared/models/institutions/institutions.models'; export interface NodeDetailsModel { id: string; diff --git a/src/app/features/project/settings/settings.component.ts b/src/app/features/project/settings/settings.component.ts index 5ad55cc42..d697fe844 100644 --- a/src/app/features/project/settings/settings.component.ts +++ b/src/app/features/project/settings/settings.component.ts @@ -15,7 +15,7 @@ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { SubscriptionEvent } from '@osf/shared/enums/subscriptions/subscription-event.enum'; import { SubscriptionFrequency } from '@osf/shared/enums/subscriptions/subscription-frequency.enum'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { UpdateNodeRequestModel } from '@osf/shared/models/nodes/nodes-json-api.model'; import { ViewOnlyLinkModel } from '@osf/shared/models/view-only-links/view-only-link.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; diff --git a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts index 11741ffba..5fa7e1306 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts @@ -9,7 +9,7 @@ import { ActivatedRoute } from '@angular/router'; 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.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { FetchResourceInstitutions, FetchUserInstitutions, diff --git a/src/app/features/registry/services/registry-overview.service.ts b/src/app/features/registry/services/registry-overview.service.ts index 87d99fbc4..50c4d6454 100644 --- a/src/app/features/registry/services/registry-overview.service.ts +++ b/src/app/features/registry/services/registry-overview.service.ts @@ -13,7 +13,7 @@ import { ReviewActionsMapper } from '@osf/shared/mappers/review-actions.mapper'; import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; import { IdentifiersResponseJsonApi } from '@osf/shared/models/identifiers/identifier-json-api.model'; import { InstitutionsJsonApiResponse } from '@osf/shared/models/institutions/institution-json-api.model'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { LicenseModel } from '@osf/shared/models/license/license.model'; import { LicenseResponseJsonApi } from '@osf/shared/models/license/licenses-json-api.model'; import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; diff --git a/src/app/features/registry/store/registry/registry.model.ts b/src/app/features/registry/store/registry/registry.model.ts index 027992ed3..098aa94cd 100644 --- a/src/app/features/registry/store/registry/registry.model.ts +++ b/src/app/features/registry/store/registry/registry.model.ts @@ -1,6 +1,6 @@ import { ReviewAction } from '@osf/features/moderation/models'; import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { LicenseModel } from '@osf/shared/models/license/license.model'; import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; diff --git a/src/app/features/registry/store/registry/registry.selectors.ts b/src/app/features/registry/store/registry/registry.selectors.ts index 3f5e6a729..8adc38841 100644 --- a/src/app/features/registry/store/registry/registry.selectors.ts +++ b/src/app/features/registry/store/registry/registry.selectors.ts @@ -4,9 +4,9 @@ import { ReviewAction } from '@osf/features/moderation/models'; import { RegistrationOverviewModel } from '@osf/features/registry/models'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { LicenseModel } from '@osf/shared/models/license/license.model'; import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; -import { Institution } from '@shared/models/institutions/institutions.models'; import { PageSchema } from '@shared/models/registration/page-schema.model'; import { RegistryStateModel } from './registry.model'; diff --git a/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.ts b/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.ts index 3168e20f1..3075e2606 100644 --- a/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.ts +++ b/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.ts @@ -10,7 +10,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { UserSelectors } from '@osf/core/store/user'; import { ReadonlyInputComponent } from '@osf/shared/components/readonly-input/readonly-input.component'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { LoaderService } from '@osf/shared/services/loader.service'; import { ToastService } from '@osf/shared/services/toast.service'; diff --git a/src/app/features/settings/account-settings/services/account-settings.service.ts b/src/app/features/settings/account-settings/services/account-settings.service.ts index 227cd29a0..f46142573 100644 --- a/src/app/features/settings/account-settings/services/account-settings.service.ts +++ b/src/app/features/settings/account-settings/services/account-settings.service.ts @@ -4,7 +4,7 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { UserMapper } from '@osf/shared/mappers/user'; -import { UserModel } from '@osf/shared/models/user/user.models'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { UserDataJsonApi } from '@osf/shared/models/user/user-json-api.model'; import { JsonApiService } from '@osf/shared/services/json-api.service'; diff --git a/src/app/features/settings/account-settings/store/account-settings.model.ts b/src/app/features/settings/account-settings/store/account-settings.model.ts index a81ac14ac..68d2c764b 100644 --- a/src/app/features/settings/account-settings/store/account-settings.model.ts +++ b/src/app/features/settings/account-settings/store/account-settings.model.ts @@ -1,4 +1,4 @@ -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; import { AccountSettings, ExternalIdentity } from '../models'; diff --git a/src/app/features/settings/account-settings/store/account-settings.selectors.ts b/src/app/features/settings/account-settings/store/account-settings.selectors.ts index bd418f0f0..e0d4e1e1b 100644 --- a/src/app/features/settings/account-settings/store/account-settings.selectors.ts +++ b/src/app/features/settings/account-settings/store/account-settings.selectors.ts @@ -1,6 +1,6 @@ import { Selector } from '@ngxs/store'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { AccountSettings, ExternalIdentity } from '../models'; diff --git a/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.ts b/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.ts index f7b4dde33..1cafd1cc2 100644 --- a/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.ts +++ b/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.ts @@ -2,7 +2,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { ChangeDetectionStrategy, Component, input } from '@angular/core'; -import { UserModel } from '@osf/shared/models/user/user.models'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { CitationFormatPipe } from '@osf/shared/pipes/citation-format.pipe'; @Component({ diff --git a/src/app/features/settings/profile-settings/components/name/name.component.ts b/src/app/features/settings/profile-settings/components/name/name.component.ts index 4d821b540..cc50b786e 100644 --- a/src/app/features/settings/profile-settings/components/name/name.component.ts +++ b/src/app/features/settings/profile-settings/components/name/name.component.ts @@ -11,10 +11,10 @@ import { FormBuilder } from '@angular/forms'; import { UpdateProfileSettingsUser, UserSelectors } from '@osf/core/store/user'; import { forbiddenFileNameCharacters } from '@osf/shared/constants/input-limits.const'; import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { LoaderService } from '@osf/shared/services/loader.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { UserModel } from '@shared/models/user/user.models'; import { hasNameChanges } from '../../helpers'; import { NameForm } from '../../models'; diff --git a/src/app/features/settings/profile-settings/helpers/name-comparison.helper.ts b/src/app/features/settings/profile-settings/helpers/name-comparison.helper.ts index c82d60fc8..dfde09e4f 100644 --- a/src/app/features/settings/profile-settings/helpers/name-comparison.helper.ts +++ b/src/app/features/settings/profile-settings/helpers/name-comparison.helper.ts @@ -1,5 +1,5 @@ import { findChangedFields } from '@osf/shared/helpers/find-changed-fields'; -import { UserModel } from '@osf/shared/models/user/user.models'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { NameForm } from '../models'; diff --git a/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.ts b/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.ts index c1a93ab1d..20041a0b1 100644 --- a/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.ts +++ b/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.ts @@ -18,11 +18,11 @@ import { AddonServiceNames } from '@osf/shared/enums/addon-service-names.enum'; import { AddonType } from '@osf/shared/enums/addon-type.enum'; import { ProjectAddonsStepperValue } from '@osf/shared/enums/profile-addons-stepper.enum'; import { getAddonTypeString, isAuthorizedAddon } from '@osf/shared/helpers/addon-type.helper'; +import { AuthorizedAddonRequestJsonApi } from '@osf/shared/models/addons/addon-json-api.model'; +import { AddonTerm } from '@osf/shared/models/addons/addon-utils.model'; import { AddonOAuthService } from '@osf/shared/services/addons/addon-oauth.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { AddonModel } from '@shared/models/addons/addon.model'; -import { AuthorizedAddonRequestJsonApi } from '@shared/models/addons/addon-json-api.models'; -import { AddonTerm } from '@shared/models/addons/addon-utils.models'; import { AuthorizedAccountModel } from '@shared/models/addons/authorized-account.model'; import { AddonsSelectors, CreateAuthorizedAddon, UpdateAuthorizedAddon } from '@shared/stores/addons'; diff --git a/src/app/shared/components/add-project-form/add-project-form.component.ts b/src/app/shared/components/add-project-form/add-project-form.component.ts index fc74e9c01..412038453 100644 --- a/src/app/shared/components/add-project-form/add-project-form.component.ts +++ b/src/app/shared/components/add-project-form/add-project-form.component.ts @@ -13,11 +13,11 @@ import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { UserSelectors } from '@core/store/user'; import { ProjectFormControls } from '@osf/shared/enums/create-project-form-controls.enum'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; +import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { FetchUserInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions'; import { FetchRegions, RegionsSelectors } from '@osf/shared/stores/regions'; -import { Institution } from '@shared/models/institutions/institutions.models'; import { ProjectForm } from '@shared/models/projects/create-project-form.model'; -import { ProjectModel } from '@shared/models/projects/projects.models'; import { AffiliatedInstitutionSelectComponent } from '../affiliated-institution-select/affiliated-institution-select.component'; import { ProjectSelectorComponent } from '../project-selector/project-selector.component'; diff --git a/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.ts b/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.ts index d2252a038..1941cbaa7 100644 --- a/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.ts +++ b/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.ts @@ -11,9 +11,9 @@ import { RouterLink } from '@angular/router'; import { AddonFormControls } from '@osf/shared/enums/addon-form-controls.enum'; import { CredentialsFormat } from '@osf/shared/enums/addons-credentials-format.enum'; +import { AuthorizedAddonRequestJsonApi } from '@osf/shared/models/addons/addon-json-api.model'; +import { AddonForm } from '@osf/shared/models/addons/addon-utils.model'; import { AddonModel } from '@shared/models/addons/addon.model'; -import { AuthorizedAddonRequestJsonApi } from '@shared/models/addons/addon-json-api.models'; -import { AddonForm } from '@shared/models/addons/addon-utils.models'; import { AuthorizedAccountModel } from '@shared/models/addons/authorized-account.model'; import { AddonFormService } from '@shared/services/addons/addon-form.service'; diff --git a/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts b/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts index 0633723f8..fbfeaea93 100644 --- a/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts +++ b/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts @@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ADDON_TERMS } from '@osf/shared/constants/addon-terms.const'; import { isCitationAddon, isRedirectAddon } from '@osf/shared/helpers/addon-type.helper'; import { AddonModel } from '@osf/shared/models/addons/addon.model'; -import { AddonTerm } from '@osf/shared/models/addons/addon-utils.models'; +import { AddonTerm } from '@osf/shared/models/addons/addon-utils.model'; import { AddonTermsComponent } from './addon-terms.component'; diff --git a/src/app/shared/components/addons/addon-terms/addon-terms.component.ts b/src/app/shared/components/addons/addon-terms/addon-terms.component.ts index 464a1f5b4..b2273ae26 100644 --- a/src/app/shared/components/addons/addon-terms/addon-terms.component.ts +++ b/src/app/shared/components/addons/addon-terms/addon-terms.component.ts @@ -7,8 +7,8 @@ import { Component, computed, input } from '@angular/core'; import { ADDON_TERMS } from '@osf/shared/constants/addon-terms.const'; import { isCitationAddon, isRedirectAddon } from '@osf/shared/helpers/addon-type.helper'; +import { AddonTerm } from '@osf/shared/models/addons/addon-utils.model'; import { AddonModel } from '@shared/models/addons/addon.model'; -import { AddonTerm } from '@shared/models/addons/addon-utils.models'; import { AuthorizedAccountModel } from '@shared/models/addons/authorized-account.model'; @Component({ diff --git a/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.ts b/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.ts index 3f8588d07..26291fadc 100644 --- a/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.ts +++ b/src/app/shared/components/addons/storage-item-selector/storage-item-selector.component.ts @@ -36,9 +36,9 @@ import { OperationNames } from '@osf/shared/enums/operation-names.enum'; import { StorageItemType } from '@osf/shared/enums/storage-item-type.enum'; import { IS_XSMALL } from '@osf/shared/helpers/breakpoints.tokens'; import { convertCamelCaseToNormal } from '@osf/shared/helpers/camel-case-to-normal.helper'; +import { OperationInvokeData } from '@osf/shared/models/addons/addon-utils.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { AddonsSelectors, ClearOperationInvocations } from '@osf/shared/stores/addons'; -import { OperationInvokeData } from '@shared/models/addons/addon-utils.models'; import { StorageItem } from '@shared/models/addons/storage-item.model'; import { GoogleFilePickerComponent } from '../../google-file-picker/google-file-picker.component'; diff --git a/src/app/shared/components/affiliated-institution-select/affiliated-institution-select.component.spec.ts b/src/app/shared/components/affiliated-institution-select/affiliated-institution-select.component.spec.ts index 91a90646d..cddac3c53 100644 --- a/src/app/shared/components/affiliated-institution-select/affiliated-institution-select.component.spec.ts +++ b/src/app/shared/components/affiliated-institution-select/affiliated-institution-select.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Institution } from '@shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { AffiliatedInstitutionSelectComponent } from './affiliated-institution-select.component'; diff --git a/src/app/shared/components/affiliated-institution-select/affiliated-institution-select.component.ts b/src/app/shared/components/affiliated-institution-select/affiliated-institution-select.component.ts index 9f7b0d4e7..4fd7f261d 100644 --- a/src/app/shared/components/affiliated-institution-select/affiliated-institution-select.component.ts +++ b/src/app/shared/components/affiliated-institution-select/affiliated-institution-select.component.ts @@ -8,7 +8,7 @@ import { NgOptimizedImage } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, input, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; @Component({ selector: 'osf-affiliated-institution-select', diff --git a/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.spec.ts b/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.spec.ts index 497468a59..4724ab97a 100644 --- a/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.spec.ts +++ b/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Institution } from '@shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { AffiliatedInstitutionsViewComponent } from './affiliated-institutions-view.component'; diff --git a/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.ts b/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.ts index 8edecaf23..9f8d45cce 100644 --- a/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.ts +++ b/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.ts @@ -6,7 +6,7 @@ import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { RouterLink } from '@angular/router'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; @Component({ selector: 'osf-affiliated-institutions-view', diff --git a/src/app/shared/components/bar-chart/bar-chart.component.ts b/src/app/shared/components/bar-chart/bar-chart.component.ts index e1184f9e7..dce54adc1 100644 --- a/src/app/shared/components/bar-chart/bar-chart.component.ts +++ b/src/app/shared/components/bar-chart/bar-chart.component.ts @@ -7,7 +7,7 @@ import { isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, input, OnInit, PLATFORM_ID, signal } from '@angular/core'; import { PIE_CHART_PALETTE } from '@osf/shared/constants/pie-chart-palette'; -import { DatasetInput } from '@shared/models/charts/dataset-input'; +import { DatasetInput } from '@osf/shared/models/charts/dataset-input.model'; import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component'; diff --git a/src/app/shared/components/doughnut-chart/doughnut-chart.component.ts b/src/app/shared/components/doughnut-chart/doughnut-chart.component.ts index edf3b3123..c22a9cb75 100644 --- a/src/app/shared/components/doughnut-chart/doughnut-chart.component.ts +++ b/src/app/shared/components/doughnut-chart/doughnut-chart.component.ts @@ -7,7 +7,7 @@ import { isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, input, OnInit, PLATFORM_ID, signal } from '@angular/core'; import { PIE_CHART_PALETTE } from '@osf/shared/constants/pie-chart-palette'; -import { DatasetInput } from '@shared/models/charts/dataset-input'; +import { DatasetInput } from '@osf/shared/models/charts/dataset-input.model'; import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component'; diff --git a/src/app/shared/components/license/license.component.ts b/src/app/shared/components/license/license.component.ts index e81226cfb..6d8823827 100644 --- a/src/app/shared/components/license/license.component.ts +++ b/src/app/shared/components/license/license.component.ts @@ -12,8 +12,8 @@ import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angul import { InputLimits } from '@osf/shared/constants/input-limits.const'; import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; import { StringOrNullOrUndefined } from '@osf/shared/helpers/types.helper'; +import { LicenseForm } from '@osf/shared/models/license/license-form.model'; import { LicenseModel, LicenseOptions } from '@shared/models/license/license.model'; -import { LicenseForm } from '@shared/models/license/license-form.models'; import { InterpolatePipe } from '@shared/pipes/interpolate.pipe'; import { TextInputComponent } from '../text-input/text-input.component'; diff --git a/src/app/shared/components/line-chart/line-chart.component.spec.ts b/src/app/shared/components/line-chart/line-chart.component.spec.ts index 3d2938fbf..9e148757f 100644 --- a/src/app/shared/components/line-chart/line-chart.component.spec.ts +++ b/src/app/shared/components/line-chart/line-chart.component.spec.ts @@ -5,7 +5,7 @@ import { ChartModule } from 'primeng/chart'; import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DatasetInput } from '@osf/shared/models/charts/dataset-input'; +import { DatasetInput } from '@osf/shared/models/charts/dataset-input.model'; import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component'; diff --git a/src/app/shared/components/line-chart/line-chart.component.ts b/src/app/shared/components/line-chart/line-chart.component.ts index fac02dab3..7462312b5 100644 --- a/src/app/shared/components/line-chart/line-chart.component.ts +++ b/src/app/shared/components/line-chart/line-chart.component.ts @@ -14,7 +14,7 @@ import { signal, } from '@angular/core'; -import { DatasetInput } from '@osf/shared/models/charts/dataset-input'; +import { DatasetInput } from '@osf/shared/models/charts/dataset-input.model'; import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component'; diff --git a/src/app/shared/components/my-projects-table/my-projects-table.component.spec.ts b/src/app/shared/components/my-projects-table/my-projects-table.component.spec.ts index 5aa148c0e..ffb134af6 100644 --- a/src/app/shared/components/my-projects-table/my-projects-table.component.spec.ts +++ b/src/app/shared/components/my-projects-table/my-projects-table.component.spec.ts @@ -3,8 +3,8 @@ import { MockComponents } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SortOrder } from '@osf/shared/enums/sort-order.enum'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model'; import { TableParameters } from '@osf/shared/models/table-parameters.model'; -import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models'; import { ContributorsListShortenerComponent } from '../contributors-list-shortener/contributors-list-shortener.component'; import { IconComponent } from '../icon/icon.component'; diff --git a/src/app/shared/components/my-projects-table/my-projects-table.component.ts b/src/app/shared/components/my-projects-table/my-projects-table.component.ts index 22472efb9..0cd62cda7 100644 --- a/src/app/shared/components/my-projects-table/my-projects-table.component.ts +++ b/src/app/shared/components/my-projects-table/my-projects-table.component.ts @@ -8,7 +8,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { SortOrder } from '@osf/shared/enums/sort-order.enum'; -import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.models'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model'; import { TableParameters } from '@osf/shared/models/table-parameters.model'; import { ContributorsListShortenerComponent } from '../contributors-list-shortener/contributors-list-shortener.component'; diff --git a/src/app/shared/components/pie-chart/pie-chart.component.spec.ts b/src/app/shared/components/pie-chart/pie-chart.component.spec.ts index 9d0f4ef4a..0990a79d6 100644 --- a/src/app/shared/components/pie-chart/pie-chart.component.spec.ts +++ b/src/app/shared/components/pie-chart/pie-chart.component.spec.ts @@ -5,7 +5,7 @@ import { ChartModule } from 'primeng/chart'; import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DatasetInput } from '@shared/models/charts/dataset-input'; +import { DatasetInput } from '@osf/shared/models/charts/dataset-input.model'; import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component'; diff --git a/src/app/shared/components/pie-chart/pie-chart.component.ts b/src/app/shared/components/pie-chart/pie-chart.component.ts index 2c36e139f..a1c8968a6 100644 --- a/src/app/shared/components/pie-chart/pie-chart.component.ts +++ b/src/app/shared/components/pie-chart/pie-chart.component.ts @@ -15,7 +15,7 @@ import { } from '@angular/core'; import { PIE_CHART_PALETTE } from '@osf/shared/constants/pie-chart-palette'; -import { DatasetInput } from '@shared/models/charts/dataset-input'; +import { DatasetInput } from '@osf/shared/models/charts/dataset-input.model'; import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component'; diff --git a/src/app/shared/components/project-selector/project-selector.component.ts b/src/app/shared/components/project-selector/project-selector.component.ts index d150dc205..5cd711e0f 100644 --- a/src/app/shared/components/project-selector/project-selector.component.ts +++ b/src/app/shared/components/project-selector/project-selector.component.ts @@ -22,7 +22,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { UserSelectors } from '@core/store/user'; -import { ProjectModel } from '@shared/models/projects/projects.models'; +import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { CustomOption } from '@shared/models/select-option.model'; import { GetProjects, ProjectsSelectors } from '@shared/stores/projects'; diff --git a/src/app/shared/constants/addon-terms.const.ts b/src/app/shared/constants/addon-terms.const.ts index 936331982..18eeda651 100644 --- a/src/app/shared/constants/addon-terms.const.ts +++ b/src/app/shared/constants/addon-terms.const.ts @@ -1,4 +1,4 @@ -import { Term } from '../models/addons/addon-utils.models'; +import { Term } from '../models/addons/addon-utils.model'; export const ADDON_TERMS: Term[] = [ { diff --git a/src/app/shared/helpers/search-total-count.helper.ts b/src/app/shared/helpers/search-total-count.helper.ts index 49ede8e45..48a236f1f 100644 --- a/src/app/shared/helpers/search-total-count.helper.ts +++ b/src/app/shared/helpers/search-total-count.helper.ts @@ -1,4 +1,4 @@ -import { IndexCardSearchResponseJsonApi } from '../models/search/index-card-search-json-api.models'; +import { IndexCardSearchResponseJsonApi } from '../models/search/index-card-search-json-api.model'; export function parseSearchTotalCount(response: IndexCardSearchResponseJsonApi): number { let totalCount = 0; diff --git a/src/app/shared/mappers/addon.mapper.ts b/src/app/shared/mappers/addon.mapper.ts index 3b6616c39..65d90eece 100644 --- a/src/app/shared/mappers/addon.mapper.ts +++ b/src/app/shared/mappers/addon.mapper.ts @@ -6,11 +6,11 @@ import { AuthorizedAddonGetResponseJsonApi, ConfiguredAddonGetResponseJsonApi, IncludedAddonData, -} from '../models/addons/addon-json-api.models'; +} from '../models/addons/addon-json-api.model'; import { OperationInvocationResponseJsonApi, StorageItemResponseJsonApi, -} from '../models/addons/addon-operations-json-api.models'; +} from '../models/addons/addon-operations-json-api.model'; import { AuthorizedAccountModel } from '../models/addons/authorized-account.model'; import { ConfiguredAddonModel } from '../models/addons/configured-addon.model'; import { OperationInvocation } from '../models/addons/operation-invocation.model'; diff --git a/src/app/shared/mappers/collections/collections.mapper.ts b/src/app/shared/mappers/collections/collections.mapper.ts index d1fd54ca5..227fbe021 100644 --- a/src/app/shared/mappers/collections/collections.mapper.ts +++ b/src/app/shared/mappers/collections/collections.mapper.ts @@ -11,13 +11,13 @@ import { CollectionProvider, CollectionSubmission, CollectionSubmissionWithGuid, -} from '@osf/shared/models/collections/collections.models'; +} from '@osf/shared/models/collections/collections.model'; import { CollectionDetailsResponseJsonApi, CollectionProviderResponseJsonApi, CollectionSubmissionJsonApi, CollectionSubmissionWithGuidJsonApi, -} from '@osf/shared/models/collections/collections-json-api.models'; +} from '@osf/shared/models/collections/collections-json-api.model'; import { ResponseJsonApi } from '@osf/shared/models/common/json-api.model'; import { ContributorModel } from '@osf/shared/models/contributors/contributor.model'; import { PaginatedData } from '@osf/shared/models/paginated-data.model'; diff --git a/src/app/shared/mappers/filters/filter-option.mapper.ts b/src/app/shared/mappers/filters/filter-option.mapper.ts index 21fd54c13..df77250b3 100644 --- a/src/app/shared/mappers/filters/filter-option.mapper.ts +++ b/src/app/shared/mappers/filters/filter-option.mapper.ts @@ -1,6 +1,6 @@ import { FilterOption } from '@osf/shared/models/search/discaverable-filter.model'; -import { FilterOptionItem } from '@osf/shared/models/search/filter-options-json-api.models'; -import { SearchResultDataJsonApi } from '@osf/shared/models/search/index-card-search-json-api.models'; +import { FilterOptionItem } from '@osf/shared/models/search/filter-options-json-api.model'; +import { SearchResultDataJsonApi } from '@osf/shared/models/search/index-card-search-json-api.model'; export function mapFilterOptions( searchResultItems: SearchResultDataJsonApi[], diff --git a/src/app/shared/mappers/filters/filters.mapper.ts b/src/app/shared/mappers/filters/filters.mapper.ts index dde219558..6cee8c2d6 100644 --- a/src/app/shared/mappers/filters/filters.mapper.ts +++ b/src/app/shared/mappers/filters/filters.mapper.ts @@ -2,7 +2,7 @@ import { DiscoverableFilter, FilterOperatorOption } from '@osf/shared/models/sea import { IndexCardSearchResponseJsonApi, RelatedPropertyPathDataJsonApi, -} from '@osf/shared/models/search/index-card-search-json-api.models'; +} from '@osf/shared/models/search/index-card-search-json-api.model'; export function MapFilters(indexCardSearchResponseJsonApi: IndexCardSearchResponseJsonApi): DiscoverableFilter[] { const relatedPropertiesIds = indexCardSearchResponseJsonApi.data.relationships.relatedProperties.data.map( diff --git a/src/app/shared/mappers/institutions/institutions.mapper.ts b/src/app/shared/mappers/institutions/institutions.mapper.ts index 66642d805..f3552702a 100644 --- a/src/app/shared/mappers/institutions/institutions.mapper.ts +++ b/src/app/shared/mappers/institutions/institutions.mapper.ts @@ -3,7 +3,7 @@ import { InstitutionsJsonApiResponse, InstitutionsWithMetaJsonApiResponse, } from '@osf/shared/models/institutions/institution-json-api.model'; -import { Institution, InstitutionsWithTotalCount } from '@osf/shared/models/institutions/institutions.models'; +import { Institution, InstitutionsWithTotalCount } from '@osf/shared/models/institutions/institutions.model'; import { replaceBadEncodedChars } from '@shared/helpers/format-bad-encoding.helper'; export class InstitutionsMapper { diff --git a/src/app/shared/mappers/my-resources.mapper.ts b/src/app/shared/mappers/my-resources.mapper.ts index 7a2a5d5d2..d7caf4a08 100644 --- a/src/app/shared/mappers/my-resources.mapper.ts +++ b/src/app/shared/mappers/my-resources.mapper.ts @@ -1,6 +1,6 @@ import { replaceBadEncodedChars } from '@shared/helpers/format-bad-encoding.helper'; -import { MyResourcesItem, MyResourcesItemGetResponseJsonApi } from '../models/my-resources/my-resources.models'; +import { MyResourcesItem, MyResourcesItemGetResponseJsonApi } from '../models/my-resources/my-resources.model'; import { ContributorsMapper } from './contributors'; diff --git a/src/app/shared/mappers/projects/projects.mapper.ts b/src/app/shared/mappers/projects/projects.mapper.ts index 26140a3b9..5f6ff0ac5 100644 --- a/src/app/shared/mappers/projects/projects.mapper.ts +++ b/src/app/shared/mappers/projects/projects.mapper.ts @@ -1,8 +1,8 @@ -import { CollectionSubmissionMetadataPayloadJsonApi } from '@osf/features/collections/models/collection-license-json-api.models'; +import { CollectionSubmissionMetadataPayloadJsonApi } from '@osf/features/collections/models/collection-license-json-api.model'; import { BaseNodeDataJsonApi } from '@osf/shared/models/nodes/base-node-data-json-api.model'; import { NodesResponseJsonApi } from '@osf/shared/models/nodes/nodes-json-api.model'; import { ProjectMetadataUpdatePayload } from '@osf/shared/models/project-metadata-update-payload.model'; -import { ProjectModel } from '@osf/shared/models/projects/projects.models'; +import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { replaceBadEncodedChars } from '@shared/helpers/format-bad-encoding.helper'; export class ProjectsMapper { diff --git a/src/app/shared/mappers/search/search.mapper.ts b/src/app/shared/mappers/search/search.mapper.ts index e0aad24e9..876048744 100644 --- a/src/app/shared/mappers/search/search.mapper.ts +++ b/src/app/shared/mappers/search/search.mapper.ts @@ -1,10 +1,10 @@ -import { ResourceType } from '@shared/enums/resource-type.enum'; -import { replaceBadEncodedChars } from '@shared/helpers/format-bad-encoding.helper'; import { IndexCardDataJsonApi, IndexCardSearchResponseJsonApi, SearchResultDataJsonApi, -} from '@shared/models/search/index-card-search-json-api.models'; +} from '@osf/shared/models/search/index-card-search-json-api.model'; +import { ResourceType } from '@shared/enums/resource-type.enum'; +import { replaceBadEncodedChars } from '@shared/helpers/format-bad-encoding.helper'; import { ResourceModel } from '@shared/models/search/resource.model'; export function MapResources(indexCardSearchResponseJsonApi: IndexCardSearchResponseJsonApi): ResourceModel[] { diff --git a/src/app/shared/mappers/user/user.mapper.ts b/src/app/shared/mappers/user/user.mapper.ts index 2a735dd38..f3df82497 100644 --- a/src/app/shared/mappers/user/user.mapper.ts +++ b/src/app/shared/mappers/user/user.mapper.ts @@ -1,4 +1,4 @@ -import { UserData, UserModel } from '@osf/shared/models/user/user.models'; +import { UserData, UserModel } from '@osf/shared/models/user/user.model'; import { UserAcceptedTermsOfServiceJsonApi, UserAttributesJsonApi, diff --git a/src/app/shared/models/addons/addon-json-api.models.ts b/src/app/shared/models/addons/addon-json-api.model.ts similarity index 100% rename from src/app/shared/models/addons/addon-json-api.models.ts rename to src/app/shared/models/addons/addon-json-api.model.ts diff --git a/src/app/shared/models/addons/addon-operations-json-api.models.ts b/src/app/shared/models/addons/addon-operations-json-api.model.ts similarity index 100% rename from src/app/shared/models/addons/addon-operations-json-api.models.ts rename to src/app/shared/models/addons/addon-operations-json-api.model.ts diff --git a/src/app/shared/models/addons/addon-utils.models.ts b/src/app/shared/models/addons/addon-utils.model.ts similarity index 100% rename from src/app/shared/models/addons/addon-utils.models.ts rename to src/app/shared/models/addons/addon-utils.model.ts diff --git a/src/app/shared/models/charts/dataset-input.ts b/src/app/shared/models/charts/dataset-input.model.ts similarity index 100% rename from src/app/shared/models/charts/dataset-input.ts rename to src/app/shared/models/charts/dataset-input.model.ts diff --git a/src/app/shared/models/collections/collections-json-api.models.ts b/src/app/shared/models/collections/collections-json-api.model.ts similarity index 100% rename from src/app/shared/models/collections/collections-json-api.models.ts rename to src/app/shared/models/collections/collections-json-api.model.ts diff --git a/src/app/shared/models/collections/collections.models.ts b/src/app/shared/models/collections/collections.model.ts similarity index 97% rename from src/app/shared/models/collections/collections.models.ts rename to src/app/shared/models/collections/collections.model.ts index c130c21f1..5b27a3bff 100644 --- a/src/app/shared/models/collections/collections.models.ts +++ b/src/app/shared/models/collections/collections.model.ts @@ -3,7 +3,7 @@ import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-su import { BrandModel } from '../brand/brand.model'; import { ContributorModel } from '../contributors/contributor.model'; -import { ProjectModel } from '../projects/projects.models'; +import { ProjectModel } from '../projects/projects.model'; import { BaseProviderModel } from '../provider/provider.model'; export interface CollectionProvider extends BaseProviderModel { diff --git a/src/app/shared/models/institutions/institution-json-api.model.ts b/src/app/shared/models/institutions/institution-json-api.model.ts index 16f4b4226..cedc24e36 100644 --- a/src/app/shared/models/institutions/institution-json-api.model.ts +++ b/src/app/shared/models/institutions/institution-json-api.model.ts @@ -1,6 +1,6 @@ import { ApiData, JsonApiResponse, ResponseJsonApi } from '../common/json-api.model'; -import { InstitutionAssets } from './institutions.models'; +import { InstitutionAssets } from './institutions.model'; export type InstitutionsJsonApiResponse = JsonApiResponse; export type InstitutionsWithMetaJsonApiResponse = ResponseJsonApi; diff --git a/src/app/shared/models/institutions/institutions.models.ts b/src/app/shared/models/institutions/institutions.model.ts similarity index 100% rename from src/app/shared/models/institutions/institutions.models.ts rename to src/app/shared/models/institutions/institutions.model.ts diff --git a/src/app/shared/models/license/license-form.models.ts b/src/app/shared/models/license/license-form.model.ts similarity index 100% rename from src/app/shared/models/license/license-form.models.ts rename to src/app/shared/models/license/license-form.model.ts diff --git a/src/app/shared/models/my-resources/my-resources-search-filters.models.ts b/src/app/shared/models/my-resources/my-resources-search-filters.model.ts similarity index 100% rename from src/app/shared/models/my-resources/my-resources-search-filters.models.ts rename to src/app/shared/models/my-resources/my-resources-search-filters.model.ts diff --git a/src/app/shared/models/my-resources/my-resources.models.ts b/src/app/shared/models/my-resources/my-resources.model.ts similarity index 100% rename from src/app/shared/models/my-resources/my-resources.models.ts rename to src/app/shared/models/my-resources/my-resources.model.ts diff --git a/src/app/shared/models/profile-settings-update.model.ts b/src/app/shared/models/profile-settings-update.model.ts index a80257609..f87f5c7c8 100644 --- a/src/app/shared/models/profile-settings-update.model.ts +++ b/src/app/shared/models/profile-settings-update.model.ts @@ -1,7 +1,7 @@ import { Education } from './user/education.model'; import { Employment } from './user/employment.model'; import { SocialModel } from './user/social.model'; -import { UserModel } from './user/user.models'; +import { UserModel } from './user/user.model'; export type ProfileSettingsUpdate = | Partial[] diff --git a/src/app/shared/models/projects/projects.models.ts b/src/app/shared/models/projects/projects.model.ts similarity index 100% rename from src/app/shared/models/projects/projects.models.ts rename to src/app/shared/models/projects/projects.model.ts diff --git a/src/app/shared/models/registration/draft-registration.model.ts b/src/app/shared/models/registration/draft-registration.model.ts index 4a18222ac..7ae2a5a0a 100644 --- a/src/app/shared/models/registration/draft-registration.model.ts +++ b/src/app/shared/models/registration/draft-registration.model.ts @@ -1,7 +1,7 @@ import { UserPermissions } from '@shared/enums/user-permissions.enum'; import { LicenseOptions } from '../license/license.model'; -import { ProjectModel } from '../projects/projects.models'; +import { ProjectModel } from '../projects/projects.model'; export interface DraftRegistrationModel { id: string; diff --git a/src/app/shared/models/request-access/request-access.model.ts b/src/app/shared/models/request-access/request-access.model.ts index 0b3b5cf39..cf987ae92 100644 --- a/src/app/shared/models/request-access/request-access.model.ts +++ b/src/app/shared/models/request-access/request-access.model.ts @@ -1,6 +1,6 @@ import { ContributorPermission } from '@osf/shared/enums/contributors/contributor-permission.enum'; -import { UserModel } from '../user/user.models'; +import { UserModel } from '../user/user.model'; export interface RequestAccessModel { id: string; diff --git a/src/app/shared/models/search/filter-options-json-api.models.ts b/src/app/shared/models/search/filter-options-json-api.model.ts similarity index 98% rename from src/app/shared/models/search/filter-options-json-api.models.ts rename to src/app/shared/models/search/filter-options-json-api.model.ts index d9de2d9dd..dbcf6ba9a 100644 --- a/src/app/shared/models/search/filter-options-json-api.models.ts +++ b/src/app/shared/models/search/filter-options-json-api.model.ts @@ -1,6 +1,6 @@ import { ApiData } from '../common/json-api.model'; -import { SearchResultDataJsonApi } from './index-card-search-json-api.models'; +import { SearchResultDataJsonApi } from './index-card-search-json-api.model'; export interface FilterOptionsResponseJsonApi { data: FilterOptionsResponseData; diff --git a/src/app/shared/models/search/index-card-search-json-api.models.ts b/src/app/shared/models/search/index-card-search-json-api.model.ts similarity index 100% rename from src/app/shared/models/search/index-card-search-json-api.models.ts rename to src/app/shared/models/search/index-card-search-json-api.model.ts diff --git a/src/app/shared/models/user/user.models.ts b/src/app/shared/models/user/user.model.ts similarity index 100% rename from src/app/shared/models/user/user.models.ts rename to src/app/shared/models/user/user.model.ts diff --git a/src/app/shared/pipes/citation-format.pipe.ts b/src/app/shared/pipes/citation-format.pipe.ts index bee6f5c2f..a9851bb93 100644 --- a/src/app/shared/pipes/citation-format.pipe.ts +++ b/src/app/shared/pipes/citation-format.pipe.ts @@ -1,6 +1,6 @@ import { Pipe, PipeTransform } from '@angular/core'; -import { UserModel } from '@osf/shared/models/user/user.models'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { GENERATIONAL_SUFFIXES, ORDINAL_SUFFIXES } from '../constants/citation-suffix.const'; diff --git a/src/app/shared/services/addons/addon-form.service.ts b/src/app/shared/services/addons/addon-form.service.ts index ce0fde96a..e02cf926b 100644 --- a/src/app/shared/services/addons/addon-form.service.ts +++ b/src/app/shared/services/addons/addon-form.service.ts @@ -5,12 +5,12 @@ import { AddonFormControls } from '@osf/shared/enums/addon-form-controls.enum'; import { AddonType } from '@osf/shared/enums/addon-type.enum'; import { CredentialsFormat } from '@osf/shared/enums/addons-credentials-format.enum'; import { isAuthorizedAddon } from '@osf/shared/helpers/addon-type.helper'; -import { AddonModel } from '@shared/models/addons/addon.model'; import { AuthorizedAddonRequestJsonApi, ConfiguredAddonRequestJsonApi, -} from '@shared/models/addons/addon-json-api.models'; -import { AddonForm } from '@shared/models/addons/addon-utils.models'; +} from '@osf/shared/models/addons/addon-json-api.model'; +import { AddonForm } from '@osf/shared/models/addons/addon-utils.model'; +import { AddonModel } from '@shared/models/addons/addon.model'; import { AuthorizedAccountModel } from '@shared/models/addons/authorized-account.model'; import { ConfiguredAddonModel } from '@shared/models/addons/configured-addon.model'; diff --git a/src/app/shared/services/addons/addon-oauth.service.ts b/src/app/shared/services/addons/addon-oauth.service.ts index 3ebfaae3c..f04eb88a3 100644 --- a/src/app/shared/services/addons/addon-oauth.service.ts +++ b/src/app/shared/services/addons/addon-oauth.service.ts @@ -4,7 +4,7 @@ import { isPlatformBrowser } from '@angular/common'; import { DestroyRef, inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { OAuthCallbacks } from '@osf/shared/models/addons/addon-utils.models'; +import { OAuthCallbacks } from '@osf/shared/models/addons/addon-utils.model'; import { AuthorizedAccountModel } from '@osf/shared/models/addons/authorized-account.model'; import { AddonsSelectors, DeleteAuthorizedAddon, GetAuthorizedStorageOauthToken } from '@osf/shared/stores/addons'; diff --git a/src/app/shared/services/addons/addon-operation-invocation.service.ts b/src/app/shared/services/addons/addon-operation-invocation.service.ts index 193f3cb57..8b0e3db38 100644 --- a/src/app/shared/services/addons/addon-operation-invocation.service.ts +++ b/src/app/shared/services/addons/addon-operation-invocation.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core'; import { OperationNames } from '@osf/shared/enums/operation-names.enum'; import { StorageItemType } from '@osf/shared/enums/storage-item-type.enum'; import { isCitationAddon } from '@osf/shared/helpers/addon-type.helper'; -import { OperationInvocationRequestJsonApi } from '@shared/models/addons/addon-operations-json-api.models'; +import { OperationInvocationRequestJsonApi } from '@osf/shared/models/addons/addon-operations-json-api.model'; import { AuthorizedAccountModel } from '@shared/models/addons/authorized-account.model'; import { ConfiguredAddonModel } from '@shared/models/addons/configured-addon.model'; diff --git a/src/app/shared/services/addons/addons.service.ts b/src/app/shared/services/addons/addons.service.ts index 0ebb3460b..fb574b5fb 100644 --- a/src/app/shared/services/addons/addons.service.ts +++ b/src/app/shared/services/addons/addons.service.ts @@ -21,11 +21,11 @@ import { IncludedAddonData, ResourceReferenceJsonApi, UserReferenceJsonApi, -} from '@osf/shared/models/addons/addon-json-api.models'; +} from '@osf/shared/models/addons/addon-json-api.model'; import { OperationInvocationRequestJsonApi, OperationInvocationResponseJsonApi, -} from '@osf/shared/models/addons/addon-operations-json-api.models'; +} from '@osf/shared/models/addons/addon-operations-json-api.model'; import { AuthorizedAccountModel } from '@osf/shared/models/addons/authorized-account.model'; import { ConfiguredAddonModel } from '@osf/shared/models/addons/configured-addon.model'; import { OperationInvocation } from '@osf/shared/models/addons/operation-invocation.model'; diff --git a/src/app/shared/services/bookmarks.service.ts b/src/app/shared/services/bookmarks.service.ts index 8fa7885a0..be8b29daf 100644 --- a/src/app/shared/services/bookmarks.service.ts +++ b/src/app/shared/services/bookmarks.service.ts @@ -7,13 +7,13 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { ResourceType } from '../enums/resource-type.enum'; import { SortOrder } from '../enums/sort-order.enum'; import { MyResourcesMapper } from '../mappers/my-resources.mapper'; -import { SparseCollectionsResponseJsonApi } from '../models/collections/collections-json-api.models'; +import { SparseCollectionsResponseJsonApi } from '../models/collections/collections-json-api.model'; import { MyResourcesItem, MyResourcesItemGetResponseJsonApi, MyResourcesResponseJsonApi, -} from '../models/my-resources/my-resources.models'; -import { MyResourcesSearchFilters } from '../models/my-resources/my-resources-search-filters.models'; +} from '../models/my-resources/my-resources.model'; +import { MyResourcesSearchFilters } from '../models/my-resources/my-resources-search-filters.model'; import { PaginatedData } from '../models/paginated-data.model'; import { JsonApiService } from './json-api.service'; diff --git a/src/app/shared/services/collections.service.ts b/src/app/shared/services/collections.service.ts index 6424e0f44..8b13f253a 100644 --- a/src/app/shared/services/collections.service.ts +++ b/src/app/shared/services/collections.service.ts @@ -23,7 +23,7 @@ import { CollectionSubmissionActionType, CollectionSubmissionTargetType, CollectionSubmissionWithGuid, -} from '../models/collections/collections.models'; +} from '../models/collections/collections.model'; import { CollectionDetailsGetResponseJsonApi, CollectionDetailsResponseJsonApi, @@ -31,7 +31,7 @@ import { CollectionSubmissionJsonApi, CollectionSubmissionsSearchPayloadJsonApi, CollectionSubmissionWithGuidJsonApi, -} from '../models/collections/collections-json-api.models'; +} from '../models/collections/collections-json-api.model'; import { JsonApiResponse, ResponseJsonApi } from '../models/common/json-api.model'; import { ContributorModel } from '../models/contributors/contributor.model'; import { ContributorsResponseJsonApi } from '../models/contributors/contributor-response-json-api.model'; diff --git a/src/app/shared/services/contributors.service.ts b/src/app/shared/services/contributors.service.ts index 3ab5d6145..80b123dcd 100644 --- a/src/app/shared/services/contributors.service.ts +++ b/src/app/shared/services/contributors.service.ts @@ -14,7 +14,7 @@ import { ContributorModel } from '../models/contributors/contributor.model'; import { ContributorAddModel } from '../models/contributors/contributor-add.model'; import { ContributorsResponseJsonApi } from '../models/contributors/contributor-response-json-api.model'; import { PaginatedData } from '../models/paginated-data.model'; -import { IndexCardSearchResponseJsonApi } from '../models/search/index-card-search-json-api.models'; +import { IndexCardSearchResponseJsonApi } from '../models/search/index-card-search-json-api.model'; import { SearchUserDataModel } from '../models/user/search-user-data.model'; import { JsonApiService } from './json-api.service'; diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts index 0fd3306e0..e980fbe56 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -23,7 +23,7 @@ import { AddonMapper } from '../mappers/addon.mapper'; import { ContributorsMapper } from '../mappers/contributors'; import { FilesMapper } from '../mappers/files/files.mapper'; import { AddonModel } from '../models/addons/addon.model'; -import { AddonGetResponseJsonApi, ConfiguredAddonGetResponseJsonApi } from '../models/addons/addon-json-api.models'; +import { AddonGetResponseJsonApi, ConfiguredAddonGetResponseJsonApi } from '../models/addons/addon-json-api.model'; import { ConfiguredAddonModel } from '../models/addons/configured-addon.model'; import { ApiData, JsonApiResponse, MetaJsonApi } from '../models/common/json-api.model'; import { ContributorModel } from '../models/contributors/contributor.model'; diff --git a/src/app/shared/services/global-search.service.ts b/src/app/shared/services/global-search.service.ts index fa2f73cf0..626609c6e 100644 --- a/src/app/shared/services/global-search.service.ts +++ b/src/app/shared/services/global-search.service.ts @@ -9,11 +9,11 @@ import { mapFilterOptions } from '../mappers/filters/filter-option.mapper'; import { MapFilters } from '../mappers/filters/filters.mapper'; import { MapResources } from '../mappers/search'; import { FilterOption } from '../models/search/discaverable-filter.model'; -import { FilterOptionItem, FilterOptionsResponseJsonApi } from '../models/search/filter-options-json-api.models'; +import { FilterOptionItem, FilterOptionsResponseJsonApi } from '../models/search/filter-options-json-api.model'; import { IndexCardSearchResponseJsonApi, SearchResultDataJsonApi, -} from '../models/search/index-card-search-json-api.models'; +} from '../models/search/index-card-search-json-api.model'; import { ResourcesData } from '../models/search/resource.model'; import { JsonApiService } from './json-api.service'; diff --git a/src/app/shared/services/institutions.service.ts b/src/app/shared/services/institutions.service.ts index b90fd89ea..9fa886e97 100644 --- a/src/app/shared/services/institutions.service.ts +++ b/src/app/shared/services/institutions.service.ts @@ -12,7 +12,7 @@ import { InstitutionsJsonApiResponse, InstitutionsWithMetaJsonApiResponse, } from '../models/institutions/institution-json-api.model'; -import { Institution, InstitutionsWithTotalCount } from '../models/institutions/institutions.models'; +import { Institution, InstitutionsWithTotalCount } from '../models/institutions/institutions.model'; import { JsonApiService } from './json-api.service'; diff --git a/src/app/shared/services/my-resources.service.ts b/src/app/shared/services/my-resources.service.ts index b36e20f37..27b4159cd 100644 --- a/src/app/shared/services/my-resources.service.ts +++ b/src/app/shared/services/my-resources.service.ts @@ -14,9 +14,9 @@ import { MyResourcesItemGetResponseJsonApi, MyResourcesItemResponseJsonApi, MyResourcesResponseJsonApi, -} from '../models/my-resources/my-resources.models'; +} from '../models/my-resources/my-resources.model'; import { EndpointType } from '../models/my-resources/my-resources-endpoint.type'; -import { MyResourcesSearchFilters } from '../models/my-resources/my-resources-search-filters.models'; +import { MyResourcesSearchFilters } from '../models/my-resources/my-resources-search-filters.model'; import { CreateProjectPayloadJsoApi } from '../models/nodes/nodes-json-api.model'; import { JsonApiService } from './json-api.service'; diff --git a/src/app/shared/services/node-links.service.ts b/src/app/shared/services/node-links.service.ts index 870e9b7db..99fd68de1 100644 --- a/src/app/shared/services/node-links.service.ts +++ b/src/app/shared/services/node-links.service.ts @@ -7,7 +7,7 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { BaseNodeMapper } from '../mappers/nodes'; import { JsonApiResponse } from '../models/common/json-api.model'; -import { MyResourcesItem } from '../models/my-resources/my-resources.models'; +import { MyResourcesItem } from '../models/my-resources/my-resources.model'; import { NodeLinkJsonApi } from '../models/node-links/node-link-json-api.model'; import { NodeModel } from '../models/nodes/base-node.model'; import { NodesResponseJsonApi } from '../models/nodes/nodes-json-api.model'; diff --git a/src/app/shared/services/projects.service.ts b/src/app/shared/services/projects.service.ts index fba349ed6..dffc0054d 100644 --- a/src/app/shared/services/projects.service.ts +++ b/src/app/shared/services/projects.service.ts @@ -8,7 +8,7 @@ import { ProjectsMapper } from '../mappers/projects'; import { BaseNodeDataJsonApi } from '../models/nodes/base-node-data-json-api.model'; import { NodesResponseJsonApi } from '../models/nodes/nodes-json-api.model'; import { ProjectMetadataUpdatePayload } from '../models/project-metadata-update-payload.model'; -import { ProjectModel } from '../models/projects/projects.models'; +import { ProjectModel } from '../models/projects/projects.model'; import { JsonApiService } from './json-api.service'; diff --git a/src/app/shared/stores/addons/addons.actions.ts b/src/app/shared/stores/addons/addons.actions.ts index 17a9c852a..05b79735f 100644 --- a/src/app/shared/stores/addons/addons.actions.ts +++ b/src/app/shared/stores/addons/addons.actions.ts @@ -1,8 +1,8 @@ import { AuthorizedAddonRequestJsonApi, ConfiguredAddonRequestJsonApi, -} from '@osf/shared/models/addons/addon-json-api.models'; -import { OperationInvocationRequestJsonApi } from '@osf/shared/models/addons/addon-operations-json-api.models'; +} from '@osf/shared/models/addons/addon-json-api.model'; +import { OperationInvocationRequestJsonApi } from '@osf/shared/models/addons/addon-operations-json-api.model'; export class GetStorageAddons { static readonly type = '[Addons] Get Storage Addons'; diff --git a/src/app/shared/stores/addons/addons.models.ts b/src/app/shared/stores/addons/addons.models.ts index 14b4b37f5..a5ce2675c 100644 --- a/src/app/shared/stores/addons/addons.models.ts +++ b/src/app/shared/stores/addons/addons.models.ts @@ -3,7 +3,7 @@ import { ConfiguredAddonResponseJsonApi, ResourceReferenceJsonApi, UserReferenceJsonApi, -} from '@osf/shared/models/addons/addon-json-api.models'; +} from '@osf/shared/models/addons/addon-json-api.model'; import { AuthorizedAccountModel } from '@osf/shared/models/addons/authorized-account.model'; import { ConfiguredAddonModel } from '@osf/shared/models/addons/configured-addon.model'; import { OperationInvocation } from '@osf/shared/models/addons/operation-invocation.model'; diff --git a/src/app/shared/stores/addons/addons.selectors.ts b/src/app/shared/stores/addons/addons.selectors.ts index a552ca63a..8cf6d8cad 100644 --- a/src/app/shared/stores/addons/addons.selectors.ts +++ b/src/app/shared/stores/addons/addons.selectors.ts @@ -5,7 +5,7 @@ import { ConfiguredAddonResponseJsonApi, ResourceReferenceJsonApi, UserReferenceJsonApi, -} from '@osf/shared/models/addons/addon-json-api.models'; +} from '@osf/shared/models/addons/addon-json-api.model'; import { AuthorizedAccountModel } from '@osf/shared/models/addons/authorized-account.model'; import { ConfiguredAddonModel } from '@osf/shared/models/addons/configured-addon.model'; import { OperationInvocation } from '@osf/shared/models/addons/operation-invocation.model'; diff --git a/src/app/shared/stores/bookmarks/bookmarks.actions.ts b/src/app/shared/stores/bookmarks/bookmarks.actions.ts index 12759a916..3dcd59733 100644 --- a/src/app/shared/stores/bookmarks/bookmarks.actions.ts +++ b/src/app/shared/stores/bookmarks/bookmarks.actions.ts @@ -1,5 +1,5 @@ +import { MyResourcesSearchFilters } from '@osf/shared/models/my-resources/my-resources-search-filters.model'; import { ResourceType } from '@shared/enums/resource-type.enum'; -import { MyResourcesSearchFilters } from '@shared/models/my-resources/my-resources-search-filters.models'; export class GetBookmarksCollectionId { static readonly type = '[Bookmarks] Get Bookmarks Collection Id'; diff --git a/src/app/shared/stores/bookmarks/bookmarks.model.ts b/src/app/shared/stores/bookmarks/bookmarks.model.ts index 919d2b347..66a469992 100644 --- a/src/app/shared/stores/bookmarks/bookmarks.model.ts +++ b/src/app/shared/stores/bookmarks/bookmarks.model.ts @@ -1,4 +1,4 @@ -import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.models'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; diff --git a/src/app/shared/stores/collections/collections.model.ts b/src/app/shared/stores/collections/collections.model.ts index 8e17d8581..8ad7f5035 100644 --- a/src/app/shared/stores/collections/collections.model.ts +++ b/src/app/shared/stores/collections/collections.model.ts @@ -3,7 +3,7 @@ import { CollectionProvider, CollectionSubmission, CollectionSubmissionWithGuid, -} from '@osf/shared/models/collections/collections.models'; +} from '@osf/shared/models/collections/collections.model'; import { CollectionsFilters } from '@osf/shared/models/collections/collections-filters.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; diff --git a/src/app/shared/stores/institutions-search/institutions-search.model.ts b/src/app/shared/stores/institutions-search/institutions-search.model.ts index c319194e2..383f424d8 100644 --- a/src/app/shared/stores/institutions-search/institutions-search.model.ts +++ b/src/app/shared/stores/institutions-search/institutions-search.model.ts @@ -1,4 +1,4 @@ -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; export interface InstitutionsSearchModel { diff --git a/src/app/shared/stores/institutions-search/institutions-search.state.ts b/src/app/shared/stores/institutions-search/institutions-search.state.ts index 0600b0231..192df7d87 100644 --- a/src/app/shared/stores/institutions-search/institutions-search.state.ts +++ b/src/app/shared/stores/institutions-search/institutions-search.state.ts @@ -6,7 +6,7 @@ import { catchError, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { InstitutionsService } from '@osf/shared/services/institutions.service'; import { FetchInstitutionById } from './institutions-search.actions'; diff --git a/src/app/shared/stores/institutions/institutions.actions.ts b/src/app/shared/stores/institutions/institutions.actions.ts index 4e7790f79..85b19d02e 100644 --- a/src/app/shared/stores/institutions/institutions.actions.ts +++ b/src/app/shared/stores/institutions/institutions.actions.ts @@ -1,5 +1,5 @@ import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { Institution } from '@shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; export class FetchUserInstitutions { static readonly type = '[Institutions] Fetch User Institutions'; diff --git a/src/app/shared/stores/institutions/institutions.model.ts b/src/app/shared/stores/institutions/institutions.model.ts index 984d18a5e..939933d39 100644 --- a/src/app/shared/stores/institutions/institutions.model.ts +++ b/src/app/shared/stores/institutions/institutions.model.ts @@ -1,4 +1,4 @@ -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; diff --git a/src/app/shared/stores/my-resources/my-resources.actions.ts b/src/app/shared/stores/my-resources/my-resources.actions.ts index b56720213..195ce94f3 100644 --- a/src/app/shared/stores/my-resources/my-resources.actions.ts +++ b/src/app/shared/stores/my-resources/my-resources.actions.ts @@ -1,5 +1,5 @@ import { ResourceSearchMode } from '@osf/shared/enums/resource-search-mode.enum'; -import { MyResourcesSearchFilters } from '@osf/shared/models/my-resources/my-resources-search-filters.models'; +import { MyResourcesSearchFilters } from '@osf/shared/models/my-resources/my-resources-search-filters.model'; export class GetMyProjects { static readonly type = '[My Resources] Get Projects'; diff --git a/src/app/shared/stores/my-resources/my-resources.model.ts b/src/app/shared/stores/my-resources/my-resources.model.ts index 47d41db9f..cfc32a040 100644 --- a/src/app/shared/stores/my-resources/my-resources.model.ts +++ b/src/app/shared/stores/my-resources/my-resources.model.ts @@ -1,4 +1,4 @@ -import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.models'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model'; import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; export interface MyResourcesStateModel { diff --git a/src/app/shared/stores/my-resources/my-resources.selectors.ts b/src/app/shared/stores/my-resources/my-resources.selectors.ts index 344e64d3a..f3da50129 100644 --- a/src/app/shared/stores/my-resources/my-resources.selectors.ts +++ b/src/app/shared/stores/my-resources/my-resources.selectors.ts @@ -1,6 +1,6 @@ import { Selector } from '@ngxs/store'; -import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.models'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model'; import { MyResourcesStateModel } from './my-resources.model'; import { MyResourcesState } from './my-resources.state'; diff --git a/src/app/shared/stores/node-links/node-links.actions.ts b/src/app/shared/stores/node-links/node-links.actions.ts index 9515fb1eb..52e21e0df 100644 --- a/src/app/shared/stores/node-links/node-links.actions.ts +++ b/src/app/shared/stores/node-links/node-links.actions.ts @@ -1,5 +1,5 @@ import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; -import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.models'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model'; import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; export class CreateNodeLink { diff --git a/src/app/shared/stores/projects/projects.actions.ts b/src/app/shared/stores/projects/projects.actions.ts index 6896ba6fe..1ce5e0c10 100644 --- a/src/app/shared/stores/projects/projects.actions.ts +++ b/src/app/shared/stores/projects/projects.actions.ts @@ -1,5 +1,5 @@ +import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { ProjectMetadataUpdatePayload } from '@shared/models/project-metadata-update-payload.model'; -import { ProjectModel } from '@shared/models/projects/projects.models'; export class GetProjects { static readonly type = '[Projects] Get Projects'; diff --git a/src/app/shared/stores/projects/projects.model.ts b/src/app/shared/stores/projects/projects.model.ts index 13e2862d0..71cbcc956 100644 --- a/src/app/shared/stores/projects/projects.model.ts +++ b/src/app/shared/stores/projects/projects.model.ts @@ -1,4 +1,4 @@ -import { ProjectModel } from '@osf/shared/models/projects/projects.models'; +import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; export interface ProjectsStateModel { diff --git a/src/testing/data/collections/collection-submissions.mock.ts b/src/testing/data/collections/collection-submissions.mock.ts index cf812f95e..6dbba45b9 100644 --- a/src/testing/data/collections/collection-submissions.mock.ts +++ b/src/testing/data/collections/collection-submissions.mock.ts @@ -1,5 +1,5 @@ import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; -import { CollectionSubmission } from '@osf/shared/models/collections/collections.models'; +import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; export const MOCK_PROJECT_COLLECTION_SUBMISSIONS: CollectionSubmission[] = [ { diff --git a/src/testing/data/dashboard/dasboard.data.ts b/src/testing/data/dashboard/dasboard.data.ts index 61ea6275a..c2e133b71 100644 --- a/src/testing/data/dashboard/dasboard.data.ts +++ b/src/testing/data/dashboard/dasboard.data.ts @@ -1,4 +1,4 @@ -import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model'; import structuredClone from 'structured-clone'; diff --git a/src/testing/mocks/collections-submissions.mock.ts b/src/testing/mocks/collections-submissions.mock.ts index db217ecb6..bd04c610e 100644 --- a/src/testing/mocks/collections-submissions.mock.ts +++ b/src/testing/mocks/collections-submissions.mock.ts @@ -1,4 +1,4 @@ -import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.models'; +import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model'; export const MOCK_COLLECTION_SUBMISSION_1: CollectionSubmissionWithGuid = { id: '1', diff --git a/src/testing/mocks/data.mock.ts b/src/testing/mocks/data.mock.ts index 6acecbf35..0d24b1261 100644 --- a/src/testing/mocks/data.mock.ts +++ b/src/testing/mocks/data.mock.ts @@ -1,4 +1,4 @@ -import { UserModel } from '@osf/shared/models/user/user.models'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { UserRelatedCounts } from '@osf/shared/models/user-related-counts/user-related-counts.model'; export const MOCK_USER: UserModel = { diff --git a/src/testing/mocks/my-resources.mock.ts b/src/testing/mocks/my-resources.mock.ts index fff22e697..e726eb789 100644 --- a/src/testing/mocks/my-resources.mock.ts +++ b/src/testing/mocks/my-resources.mock.ts @@ -1,4 +1,4 @@ -import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model'; import { MOCK_CONTRIBUTOR } from './contributors.mock'; diff --git a/src/testing/mocks/project-metadata.mock.ts b/src/testing/mocks/project-metadata.mock.ts index 6972fc38a..25413d926 100644 --- a/src/testing/mocks/project-metadata.mock.ts +++ b/src/testing/mocks/project-metadata.mock.ts @@ -1,7 +1,7 @@ import { MetadataModel } from '@osf/features/metadata/models'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { IdentifierModel } from '@shared/models/identifiers/identifier.model'; -import { Institution } from '@shared/models/institutions/institutions.models'; export const MOCK_PROJECT_METADATA: MetadataModel = { id: 'project-123', diff --git a/src/testing/mocks/project.mock.ts b/src/testing/mocks/project.mock.ts index 9f5470f8c..f530cce13 100644 --- a/src/testing/mocks/project.mock.ts +++ b/src/testing/mocks/project.mock.ts @@ -1,4 +1,4 @@ -import { ProjectModel } from '@shared/models/projects/projects.models'; +import { ProjectModel } from '@osf/shared/models/projects/projects.model'; export const MOCK_PROJECT: ProjectModel = { id: 'project-1', diff --git a/src/testing/mocks/submission.mock.ts b/src/testing/mocks/submission.mock.ts index d1f601f46..8c00a91f8 100644 --- a/src/testing/mocks/submission.mock.ts +++ b/src/testing/mocks/submission.mock.ts @@ -1,5 +1,5 @@ import { PreprintSubmissionModel } from '@osf/features/moderation/models'; -import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.models'; +import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model'; import { MOCK_CONTRIBUTOR } from './contributors.mock'; diff --git a/src/testing/providers/addon-operation-invocation.service.mock.ts b/src/testing/providers/addon-operation-invocation.service.mock.ts index 293aad2a9..8da6737c7 100644 --- a/src/testing/providers/addon-operation-invocation.service.mock.ts +++ b/src/testing/providers/addon-operation-invocation.service.mock.ts @@ -1,5 +1,5 @@ +import { OperationInvocationRequestJsonApi } from '@osf/shared/models/addons/addon-operations-json-api.model'; import { AddonOperationInvocationService } from '@osf/shared/services/addons/addon-operation-invocation.service'; -import { OperationInvocationRequestJsonApi } from '@shared/models/addons/addon-operations-json-api.models'; export function AddonOperationInvocationServiceMockFactory() { return { From ae6fc3260c8219d8f63455c96b6591960ff385ac Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 11 Feb 2026 16:40:34 +0200 Subject: [PATCH 09/27] [ENG-10252] Add unit tests for the previously skipped tests in project overview and institutions (#877) - Ticket: [ENG-10252] - Feature flag: n/a ## Summary of Changes 1. Added unit tests for project overview and institutions. --- .../institutions-list.component.spec.ts | 46 +-- .../add-component-dialog.component.spec.ts | 160 +++++++++- .../delete-component-dialog.component.spec.ts | 294 +++++++++++++++++- .../duplicate-dialog.component.spec.ts | 77 ++++- .../overview-collections.component.spec.ts | 75 ++++- ...registration-custom-step.component.spec.ts | 121 +++++-- .../mocks/collections-submissions.mock.ts | 44 ++- 7 files changed, 758 insertions(+), 59 deletions(-) diff --git a/src/app/features/institutions/pages/institutions-list/institutions-list.component.spec.ts b/src/app/features/institutions/pages/institutions-list/institutions-list.component.spec.ts index 2c90dcfd1..a83e876a8 100644 --- a/src/app/features/institutions/pages/institutions-list/institutions-list.component.spec.ts +++ b/src/app/features/institutions/pages/institutions-list/institutions-list.component.spec.ts @@ -1,38 +1,30 @@ -import { MockComponents, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockComponents } from 'ng-mocks'; + +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { FormControl } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; import { ScheduledBannerComponent } from '@core/components/osf-banners/scheduled-banner/scheduled-banner.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; -import { InstitutionsSelectors } from '@osf/shared/stores/institutions'; +import { FetchInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions'; import { InstitutionsListComponent } from './institutions-list.component'; import { MOCK_INSTITUTION } from '@testing/mocks/institution.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -describe.skip('Component: Institutions List', () => { +describe('InstitutionsListComponent', () => { let component: InstitutionsListComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; - let activatedRouteMock: ReturnType; + let store: Store; const mockInstitutions = [MOCK_INSTITUTION]; - const mockTotalCount = 2; beforeEach(async () => { - routerMock = RouterMockBuilder.create().build(); - activatedRouteMock = ActivatedRouteMockBuilder.create() - .withQueryParams({ page: '1', size: '10', search: '' }) - .build(); - await TestBed.configureTestingModule({ imports: [ InstitutionsListComponent, @@ -43,17 +35,15 @@ describe.skip('Component: Institutions List', () => { provideMockStore({ signals: [ { selector: InstitutionsSelectors.getInstitutions, value: mockInstitutions }, - { selector: InstitutionsSelectors.getInstitutionsTotalCount, value: mockTotalCount }, { selector: InstitutionsSelectors.isInstitutionsLoading, value: false }, ], }), - MockProvider(Router, routerMock), - MockProvider(ActivatedRoute, activatedRouteMock), ], }).compileComponents(); fixture = TestBed.createComponent(InstitutionsListComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.detectChanges(); }); @@ -61,6 +51,26 @@ describe.skip('Component: Institutions List', () => { expect(component).toBeTruthy(); }); + it('should dispatch FetchInstitutions on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(expect.any(FetchInstitutions)); + const action = (store.dispatch as jest.Mock).mock.calls[0][0] as FetchInstitutions; + expect(action.searchValue).toBeUndefined(); + }); + + it('should dispatch FetchInstitutions with search value after debounce', fakeAsync(() => { + (store.dispatch as jest.Mock).mockClear(); + component.searchControl.setValue('test search'); + tick(300); + expect(store.dispatch).toHaveBeenCalledWith(new FetchInstitutions('test search')); + })); + + it('should dispatch FetchInstitutions with empty string when search is null', fakeAsync(() => { + (store.dispatch as jest.Mock).mockClear(); + component.searchControl.setValue(null); + tick(300); + expect(store.dispatch).toHaveBeenCalledWith(new FetchInstitutions('')); + })); + it('should initialize with correct default values', () => { expect(component.classes).toBe('flex-1 flex flex-column w-full'); expect(component.searchControl).toBeInstanceOf(FormControl); diff --git a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.spec.ts b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.spec.ts index 5538fd4f3..5fceb0708 100644 --- a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.spec.ts @@ -1,26 +1,182 @@ +import { Store } from '@ngxs/store'; + import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UserSelectors } from '@core/store/user'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; +import { ComponentFormControls } from '@osf/shared/enums/create-component-form-controls.enum'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { FetchUserInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions'; +import { FetchRegions, RegionsSelectors } from '@osf/shared/stores/regions'; + +import { CreateComponent, GetComponents, ProjectOverviewSelectors } from '../../store'; import { AddComponentDialogComponent } from './add-component-dialog.component'; -describe.skip('AddComponentComponent', () => { +import { MOCK_INSTITUTION } from '@testing/mocks/institution.mock'; +import { MOCK_PROJECT } from '@testing/mocks/project.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('AddComponentDialogComponent', () => { let component: AddComponentDialogComponent; let fixture: ComponentFixture; + let store: Store; + + const mockRegions = [{ id: 'region-1', name: 'Region 1' }]; + const mockUser = { id: 'user-1', defaultRegionId: 'user-region' } as any; + const mockProject = { ...MOCK_PROJECT, id: 'proj-1', title: 'Project', tags: ['tag1'] }; + const mockInstitutions = [MOCK_INSTITUTION]; + const mockUserInstitutions = [MOCK_INSTITUTION, { ...MOCK_INSTITUTION, id: 'inst-2', name: 'Inst 2' }]; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [AddComponentDialogComponent, MockComponent(AffiliatedInstitutionSelectComponent)], + imports: [AddComponentDialogComponent, OSFTestingModule, MockComponent(AffiliatedInstitutionSelectComponent)], + providers: [ + provideMockStore({ + signals: [ + { selector: RegionsSelectors.getRegions, value: mockRegions }, + { selector: UserSelectors.getCurrentUser, value: mockUser }, + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + { selector: ProjectOverviewSelectors.getInstitutions, value: mockInstitutions }, + { selector: RegionsSelectors.areRegionsLoading, value: false }, + { selector: ProjectOverviewSelectors.getComponentsSubmitting, value: false }, + { selector: InstitutionsSelectors.getUserInstitutions, value: mockUserInstitutions }, + { selector: InstitutionsSelectors.areUserInstitutionsLoading, value: false }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(AddComponentDialogComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); + (store.dispatch as jest.Mock).mockReturnValue(of(void 0)); fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should initialize form with default values', () => { + expect(component.componentForm.get(ComponentFormControls.Title)?.value).toBe(''); + expect(Array.isArray(component.componentForm.get(ComponentFormControls.Affiliations)?.value)).toBe(true); + expect(component.componentForm.get(ComponentFormControls.Description)?.value).toBe(''); + expect(component.componentForm.get(ComponentFormControls.AddContributors)?.value).toBe(false); + expect(component.componentForm.get(ComponentFormControls.AddTags)?.value).toBe(false); + expect(['', 'user-region']).toContain(component.componentForm.get(ComponentFormControls.StorageLocation)?.value); + }); + + it('should dispatch FetchRegions and FetchUserInstitutions on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(expect.any(FetchRegions)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(FetchUserInstitutions)); + }); + + it('should return store values from selectors', () => { + expect(component.storageLocations()).toEqual(mockRegions); + expect(component.currentUser()).toEqual(mockUser); + expect(component.currentProject()).toEqual(mockProject); + expect(component.institutions()).toEqual(mockInstitutions); + expect(component.areRegionsLoading()).toBe(false); + expect(component.isSubmitting()).toBe(false); + expect(component.userInstitutions()).toEqual(mockUserInstitutions); + expect(component.areUserInstitutionsLoading()).toBe(false); + }); + + it('should set affiliations form control from selected institutions', () => { + const institutions = [MOCK_INSTITUTION]; + component.setSelectedInstitutions(institutions); + expect(component.componentForm.get(ComponentFormControls.Affiliations)?.value).toEqual([MOCK_INSTITUTION.id]); + }); + + it('should mark form as touched and not dispatch when submitForm with invalid form', () => { + (store.dispatch as jest.Mock).mockClear(); + component.componentForm.get(ComponentFormControls.Title)?.setValue(''); + component.submitForm(); + expect(component.componentForm.touched).toBe(true); + const createCalls = (store.dispatch as jest.Mock).mock.calls.filter((c) => c[0] instanceof CreateComponent); + expect(createCalls.length).toBe(0); + }); + + it('should dispatch CreateComponent and on success close dialog, getComponents, showSuccess', () => { + component.componentForm.get(ComponentFormControls.Title)?.setValue('New Component'); + component.componentForm.get(ComponentFormControls.StorageLocation)?.setValue('region-1'); + component.componentForm.get(ComponentFormControls.Affiliations)?.setValue([MOCK_INSTITUTION.id]); + (store.dispatch as jest.Mock).mockClear(); + + component.submitForm(); + + expect(store.dispatch).toHaveBeenCalledWith( + new CreateComponent(mockProject.id, 'New Component', '', [], 'region-1', [MOCK_INSTITUTION.id], false) + ); + expect(component.dialogRef.close).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetComponents)); + expect(TestBed.inject(ToastService).showSuccess).toHaveBeenCalledWith( + 'project.overview.dialog.toast.addComponent.success' + ); + }); + + it('should pass project tags when addTags is true', () => { + component.componentForm.get(ComponentFormControls.Title)?.setValue('With Tags'); + component.componentForm.get(ComponentFormControls.StorageLocation)?.setValue('region-1'); + component.componentForm.get(ComponentFormControls.Affiliations)?.setValue([]); + component.componentForm.get(ComponentFormControls.AddTags)?.setValue(true); + (store.dispatch as jest.Mock).mockClear(); + + component.submitForm(); + + expect(store.dispatch).toHaveBeenCalledWith( + new CreateComponent(mockProject.id, 'With Tags', '', mockProject.tags, 'region-1', [], false) + ); + }); + + it('should set storage location to user default region when control empty and regions loaded', () => { + fixture = TestBed.createComponent(AddComponentDialogComponent); + component = fixture.componentInstance; + component.componentForm.get(ComponentFormControls.StorageLocation)?.setValue(''); + fixture.detectChanges(); + expect(component.componentForm.get(ComponentFormControls.StorageLocation)?.value).toBe('user-region'); + }); +}); + +describe('AddComponentDialogComponent when user has no default region', () => { + let component: AddComponentDialogComponent; + let fixture: ComponentFixture; + + const mockRegions = [{ id: 'region-1', name: 'Region 1' }]; + const mockProject = { ...MOCK_PROJECT, id: 'proj-1', title: 'Project', tags: ['tag1'] }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AddComponentDialogComponent, OSFTestingModule, MockComponent(AffiliatedInstitutionSelectComponent)], + providers: [ + provideMockStore({ + signals: [ + { selector: RegionsSelectors.getRegions, value: mockRegions }, + { selector: UserSelectors.getCurrentUser, value: null }, + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + { selector: ProjectOverviewSelectors.getInstitutions, value: [] }, + { selector: RegionsSelectors.areRegionsLoading, value: false }, + { selector: ProjectOverviewSelectors.getComponentsSubmitting, value: false }, + { selector: InstitutionsSelectors.getUserInstitutions, value: [] }, + { selector: InstitutionsSelectors.areUserInstitutionsLoading, value: false }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AddComponentDialogComponent); + component = fixture.componentInstance; + component.componentForm.get(ComponentFormControls.StorageLocation)?.setValue(''); + fixture.detectChanges(); + }); + + it('should set storage location to first region when control empty', () => { + expect(component.componentForm.get(ComponentFormControls.StorageLocation)?.value).toBe('region-1'); + }); }); diff --git a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts index 73c88f51e..b2954f518 100644 --- a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts @@ -1,22 +1,312 @@ +import { Store } from '@ngxs/store'; + +import { DynamicDialogConfig } from 'primeng/dynamicdialog'; + +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DeleteProject, SettingsSelectors } from '@osf/features/project/settings/store'; +import { RegistrySelectors } from '@osf/features/registry/store/registry'; +import { ScientistsNames } from '@osf/shared/constants/scientists.const'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; + +import { GetComponents, ProjectOverviewSelectors } from '../../store'; + import { DeleteComponentDialogComponent } from './delete-component-dialog.component'; -describe.skip('DeleteComponentDialogComponent', () => { +import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +const mockComponentsWithAdmin = [ + { id: 'comp-1', title: 'Component 1', isPublic: true, permissions: [UserPermissions.Admin] }, + { id: 'comp-2', title: 'Component 2', isPublic: false, permissions: [UserPermissions.Admin] }, +]; + +const mockComponentsWithoutAdmin = [ + { id: 'comp-1', title: 'Component 1', isPublic: true, permissions: [UserPermissions.Read] }, +]; + +describe('DeleteComponentDialogComponent', () => { let component: DeleteComponentDialogComponent; let fixture: ComponentFixture; + let store: Store; + let dialogConfig: DynamicDialogConfig; + + const mockProject = { ...MOCK_NODE_WITH_ADMIN, id: 'proj-1' }; beforeEach(async () => { + dialogConfig = { data: { resourceType: ResourceType.Project } }; + await TestBed.configureTestingModule({ - imports: [DeleteComponentDialogComponent], + imports: [DeleteComponentDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + { selector: RegistrySelectors.getRegistry, value: null }, + { selector: SettingsSelectors.isSettingsSubmitting, value: false }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: mockComponentsWithAdmin }, + ], + }), + { provide: DynamicDialogConfig, useValue: dialogConfig }, + ], }).compileComponents(); fixture = TestBed.createComponent(DeleteComponentDialogComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); + (store.dispatch as jest.Mock).mockReturnValue(of(void 0)); fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should return store values from selectors', () => { + expect(component.project()).toEqual(mockProject); + expect(component.registration()).toBeNull(); + expect(component.isSubmitting()).toBe(false); + expect(component.isLoading()).toBe(false); + expect(component.components()).toEqual(mockComponentsWithAdmin); + }); + + it('should have selectedScientist as one of ScientistsNames', () => { + expect(ScientistsNames).toContain(component.selectedScientist()); + }); + + it('should compute currentResource as project when resourceType is Project', () => { + expect(component.currentResource()).toEqual(mockProject); + }); + + it('should compute hasAdminAccessForAllComponents true when all components have Admin', () => { + expect(component.hasAdminAccessForAllComponents()).toBe(true); + }); + + it('should compute hasSubComponents true when more than one component', () => { + expect(component.hasSubComponents()).toBe(true); + }); + + it('should return isInputValid true when userInput matches selectedScientist', () => { + const scientist = component.selectedScientist(); + component.onInputChange(scientist); + expect(component.isInputValid()).toBe(true); + }); + + it('should return isInputValid false when userInput does not match', () => { + component.onInputChange('wrong'); + expect(component.isInputValid()).toBe(false); + }); + + it('should set userInput on onInputChange', () => { + component.onInputChange('test'); + expect(component.userInput()).toBe('test'); + }); + + it('should dispatch DeleteProject with components and on success close, getComponents, showSuccess', () => { + const scientist = component.selectedScientist(); + component.onInputChange(scientist); + (store.dispatch as jest.Mock).mockClear(); + + component.handleDeleteComponent(); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(DeleteProject)); + const deleteCall = (store.dispatch as jest.Mock).mock.calls.find((c) => c[0] instanceof DeleteProject); + expect(deleteCall[0].projects).toEqual(mockComponentsWithAdmin); + expect(component.dialogRef.close).toHaveBeenCalledWith({ success: true }); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetComponents)); + expect(TestBed.inject(ToastService).showSuccess).toHaveBeenCalledWith( + 'project.overview.dialog.toast.deleteComponent.success' + ); + }); +}); + +describe('DeleteComponentDialogComponent when not all components have Admin', () => { + let component: DeleteComponentDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeleteComponentDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: { ...MOCK_NODE_WITH_ADMIN, id: 'proj-1' } }, + { selector: RegistrySelectors.getRegistry, value: null }, + { selector: SettingsSelectors.isSettingsSubmitting, value: false }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: mockComponentsWithoutAdmin }, + ], + }), + { provide: DynamicDialogConfig, useValue: { data: { resourceType: ResourceType.Project } } }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(DeleteComponentDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should compute hasAdminAccessForAllComponents false', () => { + expect(component.hasAdminAccessForAllComponents()).toBe(false); + }); +}); + +describe('DeleteComponentDialogComponent when single component', () => { + let component: DeleteComponentDialogComponent; + let fixture: ComponentFixture; + + const singleComponent = [ + { id: 'comp-1', title: 'Component 1', isPublic: true, permissions: [UserPermissions.Admin] }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeleteComponentDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: { ...MOCK_NODE_WITH_ADMIN, id: 'proj-1' } }, + { selector: RegistrySelectors.getRegistry, value: null }, + { selector: SettingsSelectors.isSettingsSubmitting, value: false }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: singleComponent }, + ], + }), + { provide: DynamicDialogConfig, useValue: { data: { resourceType: ResourceType.Project } } }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(DeleteComponentDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should compute hasSubComponents false', () => { + expect(component.hasSubComponents()).toBe(false); + }); +}); + +describe('DeleteComponentDialogComponent when no components', () => { + let component: DeleteComponentDialogComponent; + let fixture: ComponentFixture; + let store: Store; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeleteComponentDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: { ...MOCK_NODE_WITH_ADMIN, id: 'proj-1' } }, + { selector: RegistrySelectors.getRegistry, value: null }, + { selector: SettingsSelectors.isSettingsSubmitting, value: false }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: [] }, + ], + }), + { provide: DynamicDialogConfig, useValue: { data: { resourceType: ResourceType.Project } } }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(DeleteComponentDialogComponent); + component = fixture.componentInstance; + store = TestBed.inject(Store); + (store.dispatch as jest.Mock).mockClear(); + fixture.detectChanges(); + }); + + it('should not dispatch when handleDeleteComponent', () => { + component.handleDeleteComponent(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); +}); + +describe('DeleteComponentDialogComponent when resourceType is Registration', () => { + let component: DeleteComponentDialogComponent; + let fixture: ComponentFixture; + + const mockRegistration = { ...MOCK_NODE_WITH_ADMIN, id: 'reg-1' }; + const mockComponentsWithAdmin = [ + { id: 'comp-1', title: 'Component 1', isPublic: true, permissions: [UserPermissions.Admin] }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeleteComponentDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: null }, + { selector: RegistrySelectors.getRegistry, value: mockRegistration }, + { selector: SettingsSelectors.isSettingsSubmitting, value: false }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: mockComponentsWithAdmin }, + ], + }), + { provide: DynamicDialogConfig, useValue: { data: { resourceType: ResourceType.Registration } } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DeleteComponentDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should compute currentResource as registration', () => { + expect(component.currentResource()).toEqual(mockRegistration); + }); +}); + +describe('DeleteComponentDialogComponent isForksContext', () => { + let component: DeleteComponentDialogComponent; + let fixture: ComponentFixture; + let store: Store; + + const mockProject = { ...MOCK_NODE_WITH_ADMIN, id: 'proj-1' }; + const mockComponentsWithAdmin = [ + { id: 'comp-1', title: 'Component 1', isPublic: true, permissions: [UserPermissions.Admin] }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeleteComponentDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + { selector: RegistrySelectors.getRegistry, value: null }, + { selector: SettingsSelectors.isSettingsSubmitting, value: false }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: mockComponentsWithAdmin }, + ], + }), + { + provide: DynamicDialogConfig, + useValue: { data: { resourceType: ResourceType.Project, isForksContext: true } }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DeleteComponentDialogComponent); + component = fixture.componentInstance; + store = TestBed.inject(Store); + (store.dispatch as jest.Mock).mockReturnValue(of(void 0)); + fixture.detectChanges(); + }); + + it('should not dispatch GetComponents when isForksContext', () => { + const scientist = component.selectedScientist(); + component.onInputChange(scientist); + (store.dispatch as jest.Mock).mockClear(); + + component.handleDeleteComponent(); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(DeleteProject)); + const getComponentsCalls = (store.dispatch as jest.Mock).mock.calls.filter((c) => c[0] instanceof GetComponents); + expect(getComponentsCalls.length).toBe(0); + }); }); diff --git a/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.spec.ts b/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.spec.ts index 5d78cf850..62f5c009a 100644 --- a/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.spec.ts @@ -1,22 +1,95 @@ +import { Store } from '@ngxs/store'; + +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { DuplicateProject, ProjectOverviewSelectors } from '../../store'; + import { DuplicateDialogComponent } from './duplicate-dialog.component'; -describe.skip('DuplicateDialogComponent', () => { +import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('DuplicateDialogComponent', () => { let component: DuplicateDialogComponent; let fixture: ComponentFixture; + let store: Store; + + const mockProject = { ...MOCK_NODE_WITH_ADMIN, id: 'proj-1', title: 'Test Project' }; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [DuplicateDialogComponent], + imports: [DuplicateDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + { selector: ProjectOverviewSelectors.getDuplicateProjectSubmitting, value: false }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(DuplicateDialogComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); + (store.dispatch as jest.Mock).mockReturnValue(of(void 0)); fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should return project and isSubmitting from store', () => { + expect(component.project()).toEqual(mockProject); + expect(component.isSubmitting()).toBe(false); + }); + + it('should dispatch DuplicateProject and on success close dialog and showSuccess', () => { + (store.dispatch as jest.Mock).mockClear(); + + component.handleDuplicateConfirm(); + + expect(store.dispatch).toHaveBeenCalledWith(new DuplicateProject(mockProject.id, mockProject.title)); + expect(component.dialogRef.close).toHaveBeenCalled(); + expect(TestBed.inject(ToastService).showSuccess).toHaveBeenCalledWith( + 'project.overview.dialog.toast.duplicate.success' + ); + }); +}); + +describe('DuplicateDialogComponent when no project', () => { + let component: DuplicateDialogComponent; + let fixture: ComponentFixture; + let store: Store; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DuplicateDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: null }, + { selector: ProjectOverviewSelectors.getDuplicateProjectSubmitting, value: false }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DuplicateDialogComponent); + component = fixture.componentInstance; + store = TestBed.inject(Store); + (store.dispatch as jest.Mock).mockClear(); + fixture.detectChanges(); + }); + + it('should not dispatch when handleDuplicateConfirm', () => { + component.handleDuplicateConfirm(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts b/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts index cbc3f5cf2..4809b1a72 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts @@ -1,14 +1,26 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { collectionFilterNames } from '@osf/features/collections/constants'; +import { CollectionSubmission } from '@osf/shared/models/collections/collections.models'; + import { OverviewCollectionsComponent } from './overview-collections.component'; -describe.skip('OverviewCollectionsComponent', () => { +import { + MOCK_COLLECTION_SUBMISSION_EMPTY_FILTERS, + MOCK_COLLECTION_SUBMISSION_SINGLE_FILTER, + MOCK_COLLECTION_SUBMISSION_STRINGIFY, + MOCK_COLLECTION_SUBMISSION_WITH_FILTERS, + MOCK_COLLECTION_SUBMISSIONS, +} from '@testing/mocks/collections-submissions.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('OverviewCollectionsComponent', () => { let component: OverviewCollectionsComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [OverviewCollectionsComponent], + imports: [OverviewCollectionsComponent, OSFTestingModule], }).compileComponents(); fixture = TestBed.createComponent(OverviewCollectionsComponent); @@ -19,4 +31,63 @@ describe.skip('OverviewCollectionsComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should have default input values', () => { + expect(component.projectSubmissions()).toBeNull(); + expect(component.isProjectSubmissionsLoading()).toBe(false); + }); + + it('should accept projectSubmissions and isProjectSubmissionsLoading via setInput', () => { + const submissions: CollectionSubmission[] = MOCK_COLLECTION_SUBMISSIONS.map((s) => ({ + ...s, + collectionTitle: s.title, + collectionId: `col-${s.id}`, + })) as CollectionSubmission[]; + fixture.componentRef.setInput('projectSubmissions', submissions); + fixture.componentRef.setInput('isProjectSubmissionsLoading', true); + fixture.detectChanges(); + expect(component.projectSubmissions()).toEqual(submissions); + expect(component.isProjectSubmissionsLoading()).toBe(true); + }); + + it('should return empty array from getSubmissionAttributes when submission has no filter values', () => { + expect(component.getSubmissionAttributes(MOCK_COLLECTION_SUBMISSION_EMPTY_FILTERS)).toEqual([]); + }); + + it('should return attributes for truthy filter keys from getSubmissionAttributes', () => { + const result = component.getSubmissionAttributes(MOCK_COLLECTION_SUBMISSION_WITH_FILTERS); + const programAreaFilter = collectionFilterNames.find((f) => f.key === 'programArea'); + const collectedTypeFilter = collectionFilterNames.find((f) => f.key === 'collectedType'); + const statusFilter = collectionFilterNames.find((f) => f.key === 'status'); + expect(result).toContainEqual({ + key: 'programArea', + label: programAreaFilter?.label, + value: 'Health', + }); + expect(result).toContainEqual({ + key: 'collectedType', + label: collectedTypeFilter?.label, + value: 'Article', + }); + expect(result).toContainEqual({ + key: 'status', + label: statusFilter?.label, + value: 'Published', + }); + expect(result.length).toBe(3); + }); + + it('should exclude falsy values from getSubmissionAttributes', () => { + const result = component.getSubmissionAttributes(MOCK_COLLECTION_SUBMISSION_SINGLE_FILTER); + expect(result).toHaveLength(1); + expect(result[0].key).toBe('collectedType'); + expect(result[0].value).toBe('Article'); + }); + + it('should stringify numeric-like values in getSubmissionAttributes', () => { + const result = component.getSubmissionAttributes(MOCK_COLLECTION_SUBMISSION_STRINGIFY); + const statusAttr = result.find((a) => a.key === 'status'); + expect(statusAttr?.value).toBe('1'); + expect(typeof statusAttr?.value).toBe('string'); + }); }); diff --git a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts index b446b84b5..0534020da 100644 --- a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts +++ b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts @@ -1,44 +1,51 @@ -import { MockComponent } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponent, MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { DraftRegistrationAttributesJsonApi } from '@osf/shared/models/registration/registration-json-api.model'; import { CustomStepComponent } from '../../components/custom-step/custom-step.component'; +import { RegistriesSelectors, UpdateDraft } from '../../store'; import { DraftRegistrationCustomStepComponent } from './draft-registration-custom-step.component'; -import { MOCK_REGISTRIES_PAGE } from '@testing/mocks/registries.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -describe.skip('DraftRegistrationCustomStepComponent', () => { +describe('DraftRegistrationCustomStepComponent', () => { let component: DraftRegistrationCustomStepComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; + let store: Store; let mockRouter: ReturnType; + let mockActivatedRoute: ReturnType; + + const mockStepsData = { stepKey: { field: 'value' } }; + const mockDraftRegistration = { + id: 'draft-1', + providerId: 'prov-1', + branchedFrom: { id: 'proj-1', filesLink: '/project/proj-1/files/' }, + }; beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1', step: '1' }).build(); - mockRouter = RouterMockBuilder.create().withUrl('/registries/prov-1/draft/draft-1/custom').build(); + mockRouter = RouterMockBuilder.create().build(); + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); await TestBed.configureTestingModule({ imports: [DraftRegistrationCustomStepComponent, OSFTestingModule, MockComponent(CustomStepComponent)], providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { provide: Router, useValue: mockRouter }, + MockProvider(Router, mockRouter), + MockProvider(ActivatedRoute, mockActivatedRoute), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getStepsData, value: {} }, - { - selector: RegistriesSelectors.getDraftRegistration, - value: { id: 'draft-1', providerId: 'prov-1', branchedFrom: { id: 'node-1', filesLink: '/files' } }, - }, - { selector: RegistriesSelectors.getPagesSchema, value: [MOCK_REGISTRIES_PAGE] }, - { selector: RegistriesSelectors.getStepsState, value: { 1: { invalid: false } } }, + { selector: RegistriesSelectors.getStepsData, value: mockStepsData }, + { selector: RegistriesSelectors.getDraftRegistration, value: mockDraftRegistration }, ], }), ], @@ -46,6 +53,8 @@ describe.skip('DraftRegistrationCustomStepComponent', () => { fixture = TestBed.createComponent(DraftRegistrationCustomStepComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); + (store.dispatch as jest.Mock).mockReturnValue(of(void 0)); fixture.detectChanges(); }); @@ -53,29 +62,81 @@ describe.skip('DraftRegistrationCustomStepComponent', () => { expect(component).toBeTruthy(); }); - it('should compute inputs from draft registration', () => { - expect(component.filesLink()).toBe('/files'); + it('should return stepsData and draftRegistration from store', () => { + expect(component.stepsData()).toEqual(mockStepsData); + expect(component.draftRegistration()).toEqual(mockDraftRegistration); + }); + + it('should compute filesLink from draftRegistration branchedFrom', () => { + expect(component.filesLink()).toBe('/project/proj-1/files/'); + }); + + it('should compute provider from draftRegistration providerId', () => { expect(component.provider()).toBe('prov-1'); - expect(component.projectId()).toBe('node-1'); }); - it('should dispatch updateDraft on onUpdateAction', () => { - const actionsMock = { updateDraft: jest.fn() } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); + it('should compute projectId from draftRegistration branchedFrom id', () => { + expect(component.projectId()).toBe('proj-1'); + }); + + it('should dispatch UpdateDraft with id and registration_responses payload on onUpdateAction', () => { + const attributes: Partial = { + registration_responses: { field1: 'value1' }, + }; + (store.dispatch as jest.Mock).mockClear(); - component.onUpdateAction({ a: 1 } as any); - expect(actionsMock.updateDraft).toHaveBeenCalledWith('draft-1', { registration_responses: { a: 1 } }); + component.onUpdateAction(attributes); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(UpdateDraft)); + const call = (store.dispatch as jest.Mock).mock.calls.find((c) => c[0] instanceof UpdateDraft); + expect(call[0].draftId).toBe('draft-1'); + expect(call[0].attributes).toEqual({ registration_responses: { registration_responses: { field1: 'value1' } } }); }); - it('should navigate back to metadata on onBack', () => { - const navigateSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + it('should navigate to ../metadata on onBack', () => { component.onBack(); - expect(navigateSpy).toHaveBeenCalledWith(['../', 'metadata'], { relativeTo: TestBed.inject(ActivatedRoute) }); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 'metadata'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); }); - it('should navigate to review on onNext', () => { - const navigateSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + it('should navigate to ../review on onNext', () => { component.onNext(); - expect(navigateSpy).toHaveBeenCalledWith(['../', 'review'], { relativeTo: TestBed.inject(ActivatedRoute) }); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 'review'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); + }); +}); + +describe('DraftRegistrationCustomStepComponent when no draft registration', () => { + let component: DraftRegistrationCustomStepComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DraftRegistrationCustomStepComponent, OSFTestingModule, MockComponent(CustomStepComponent)], + providers: [ + MockProvider(Router, RouterMockBuilder.create().build()), + MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build()), + provideMockStore({ + signals: [ + { selector: RegistriesSelectors.getStepsData, value: {} }, + { selector: RegistriesSelectors.getDraftRegistration, value: null }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DraftRegistrationCustomStepComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should compute empty filesLink provider and projectId', () => { + expect(component.filesLink()).toBe(''); + expect(component.provider()).toBe(''); + expect(component.projectId()).toBe(''); }); }); diff --git a/src/testing/mocks/collections-submissions.mock.ts b/src/testing/mocks/collections-submissions.mock.ts index bd04c610e..fcf93c12e 100644 --- a/src/testing/mocks/collections-submissions.mock.ts +++ b/src/testing/mocks/collections-submissions.mock.ts @@ -1,4 +1,5 @@ -import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model'; +import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; +import { CollectionSubmission, CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model'; export const MOCK_COLLECTION_SUBMISSION_1: CollectionSubmissionWithGuid = { id: '1', @@ -11,7 +12,7 @@ export const MOCK_COLLECTION_SUBMISSION_1: CollectionSubmissionWithGuid = { dateCreated: '2024-01-01T00:00:00Z', dateModified: '2024-01-02T00:00:00Z', public: false, - reviewsState: 'pending', + reviewsState: CollectionSubmissionReviewState.Pending, collectedType: 'preprint', status: 'pending', volume: '1', @@ -54,7 +55,7 @@ export const MOCK_COLLECTION_SUBMISSION_2: CollectionSubmissionWithGuid = { dateCreated: '2024-01-02T00:00:00Z', dateModified: '2024-01-03T00:00:00Z', public: true, - reviewsState: 'approved', + reviewsState: CollectionSubmissionReviewState.Accepted, collectedType: 'preprint', status: 'approved', volume: '2', @@ -87,3 +88,40 @@ export const MOCK_COLLECTION_SUBMISSION_2: CollectionSubmissionWithGuid = { }; export const MOCK_COLLECTION_SUBMISSIONS = [MOCK_COLLECTION_SUBMISSION_1, MOCK_COLLECTION_SUBMISSION_2]; + +export const MOCK_COLLECTION_SUBMISSION_EMPTY_FILTERS: CollectionSubmission = { + id: 'sub-1', + type: 'collection-submissions', + collectionTitle: 'Collection', + collectionId: 'col-1', + reviewsState: CollectionSubmissionReviewState.Pending, + collectedType: '', + status: '', + volume: '', + issue: '', + programArea: '', + schoolType: '', + studyDesign: '', + dataType: '', + disease: '', + gradeLevels: '', +}; + +export const MOCK_COLLECTION_SUBMISSION_WITH_FILTERS: CollectionSubmission = { + ...MOCK_COLLECTION_SUBMISSION_EMPTY_FILTERS, + reviewsState: CollectionSubmissionReviewState.Accepted, + collectedType: 'Article', + status: 'Published', + programArea: 'Health', +}; + +export const MOCK_COLLECTION_SUBMISSION_SINGLE_FILTER: CollectionSubmission = { + ...MOCK_COLLECTION_SUBMISSION_EMPTY_FILTERS, + collectedType: 'Article', +}; + +export const MOCK_COLLECTION_SUBMISSION_STRINGIFY: CollectionSubmission = { + ...MOCK_COLLECTION_SUBMISSION_EMPTY_FILTERS, + collectedType: 'Article', + status: '1', +}; From 571ea01e53f76c34879d5526dcc608f37b52e5b0 Mon Sep 17 00:00:00 2001 From: nsemets Date: Fri, 13 Feb 2026 18:17:18 +0200 Subject: [PATCH 10/27] [ENG-9157] [AOI] Add atomic ability to remove contributors from children projects in API (#884) - Ticket: [ENG-9157] - Feature flag: n/a ## Summary of Changes 1. Updated delete contributors param. 2. Added logic to get components before open delete contributors dialog. 3. Fixed delete message. --- .../contributors/contributors.component.ts | 68 +++++++++++-------- .../remove-contributor-dialog.component.html | 2 +- .../shared/services/contributors.service.ts | 8 +-- 3 files changed, 44 insertions(+), 34 deletions(-) diff --git a/src/app/features/contributors/contributors.component.ts b/src/app/features/contributors/contributors.component.ts index 8db8f09f6..9be8e6435 100644 --- a/src/app/features/contributors/contributors.component.ts +++ b/src/app/features/contributors/contributors.component.ts @@ -395,37 +395,49 @@ export class ContributorsComponent implements OnInit, OnDestroy { removeContributor(contributor: ContributorModel) { const isDeletingSelf = contributor.userId === this.currentUser()?.id; + const resourceDetails = this.resourceDetails(); + const resourceId = this.resourceId(); + const rootParentId = resourceDetails.rootParentId ?? resourceId; - this.customDialogService - .open(RemoveContributorDialogComponent, { - header: 'project.contributors.removeDialog.title', - width: '448px', - data: { - name: contributor.fullName, - hasChildren: !!this.resourceChildren()?.length, - }, - }) - .onClose.pipe( - filter((res) => res !== undefined), - switchMap((removeFromChildren: boolean) => - this.actions.deleteContributor( - this.resourceId(), - this.resourceType(), - contributor.userId, - isDeletingSelf, - removeFromChildren - ) - ), - takeUntilDestroyed(this.destroyRef) - ) + this.loaderService.show(); + + this.actions + .getResourceWithChildren(rootParentId, resourceId, this.resourceType()) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { - this.toastService.showSuccess('project.contributors.removeDialog.successMessage', { - name: contributor.fullName, - }); + this.loaderService.hide(); - if (isDeletingSelf) { - this.router.navigate(['/']); - } + this.customDialogService + .open(RemoveContributorDialogComponent, { + header: 'project.contributors.removeDialog.title', + width: '448px', + data: { + name: contributor.fullName, + hasChildren: !!this.resourceChildren()?.length, + }, + }) + .onClose.pipe( + filter((res) => res !== undefined), + switchMap((removeFromChildren: boolean) => + this.actions.deleteContributor( + this.resourceId(), + this.resourceType(), + contributor.userId, + isDeletingSelf, + removeFromChildren + ) + ), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.toastService.showSuccess('project.contributors.removeDialog.successMessage', { + name: contributor.fullName, + }); + + if (isDeletingSelf) { + this.router.navigate(['/']); + } + }); }); } diff --git a/src/app/shared/components/contributors/remove-contributor-dialog/remove-contributor-dialog.component.html b/src/app/shared/components/contributors/remove-contributor-dialog/remove-contributor-dialog.component.html index 6b579178e..520de170a 100644 --- a/src/app/shared/components/contributors/remove-contributor-dialog/remove-contributor-dialog.component.html +++ b/src/app/shared/components/contributors/remove-contributor-dialog/remove-contributor-dialog.component.html @@ -1,5 +1,5 @@
-

{{ 'project.contributors.removeDialog.message' | translate: { name: name } }}

+

diff --git a/src/app/shared/services/contributors.service.ts b/src/app/shared/services/contributors.service.ts index 80b123dcd..266ba23f2 100644 --- a/src/app/shared/services/contributors.service.ts +++ b/src/app/shared/services/contributors.service.ts @@ -248,11 +248,9 @@ export class ContributorsService { userId: string, removeFromChildren = false ): Observable { - let baseUrl = `${this.getBaseUrl(resourceType, resourceId)}/${userId}/`; - if (removeFromChildren) { - baseUrl = baseUrl.concat('?propagate_to_children=true'); - } + const baseUrl = `${this.getBaseUrl(resourceType, resourceId)}/${userId}/`; + const url = removeFromChildren ? `${baseUrl}?include_children=true` : baseUrl; - return this.jsonApiService.delete(baseUrl); + return this.jsonApiService.delete(url); } } From 5bb1c44a59b08018832229896aa58ef190892b8a Mon Sep 17 00:00:00 2001 From: nsemets Date: Tue, 17 Feb 2026 15:46:07 +0200 Subject: [PATCH 11/27] [ENG-10255] Part 1: Added unit tests for pages components in registries (#879) - Ticket: [ENG-10255] - Feature flag: n/a ## Summary of Changes 1. Added unit tests for pages components in registries --- .../constants/registrations-tabs.ts | 4 +- .../registries/enums/registration-tab.enum.ts | 4 +- ...registration-custom-step.component.spec.ts | 143 ++++------ .../justification.component.spec.ts | 253 ++++++++++++++---- .../justification/justification.component.ts | 127 +++++---- ...y-registrations-redirect.component.spec.ts | 14 - .../my-registrations.component.html | 2 +- .../my-registrations.component.spec.ts | 194 +++++--------- .../my-registrations.component.ts | 30 +-- .../registries-landing.component.spec.ts | 70 ++--- .../registries-landing.component.ts | 13 +- ...gistries-provider-search.component.spec.ts | 112 +++++--- .../revisions-custom-step.component.spec.ts | 49 ++-- .../revisions-custom-step.component.ts | 31 +-- src/testing/mocks/dynamic-dialog-ref.mock.ts | 13 + src/testing/mocks/registries.mock.ts | 42 ++- src/testing/osf.testing.provider.ts | 48 ++++ src/testing/providers/loader-service.mock.ts | 9 + src/testing/providers/route-provider.mock.ts | 18 ++ src/testing/providers/router-provider.mock.ts | 7 + 20 files changed, 690 insertions(+), 493 deletions(-) create mode 100644 src/testing/osf.testing.provider.ts diff --git a/src/app/features/registries/constants/registrations-tabs.ts b/src/app/features/registries/constants/registrations-tabs.ts index accf00f00..067163eec 100644 --- a/src/app/features/registries/constants/registrations-tabs.ts +++ b/src/app/features/registries/constants/registrations-tabs.ts @@ -1,8 +1,8 @@ -import { TabOption } from '@osf/shared/models/tab-option.model'; +import { CustomOption } from '@osf/shared/models/select-option.model'; import { RegistrationTab } from '../enums'; -export const REGISTRATIONS_TABS: TabOption[] = [ +export const REGISTRATIONS_TABS: CustomOption[] = [ { label: 'common.labels.drafts', value: RegistrationTab.Drafts, diff --git a/src/app/features/registries/enums/registration-tab.enum.ts b/src/app/features/registries/enums/registration-tab.enum.ts index 67eeac498..c7270c341 100644 --- a/src/app/features/registries/enums/registration-tab.enum.ts +++ b/src/app/features/registries/enums/registration-tab.enum.ts @@ -1,4 +1,4 @@ export enum RegistrationTab { - Drafts, - Submitted, + Drafts = 'drafts', + Submitted = 'submitted', } diff --git a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts index 0534020da..c716f46fe 100644 --- a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts +++ b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts @@ -1,99 +1,94 @@ import { Store } from '@ngxs/store'; -import { MockComponent, MockProvider } from 'ng-mocks'; - -import { of } from 'rxjs'; +import { MockComponent } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; -import { DraftRegistrationAttributesJsonApi } from '@osf/shared/models/registration/registration-json-api.model'; +import { RegistriesSelectors, UpdateDraft } from '@osf/features/registries/store'; +import { DraftRegistrationModel } from '@osf/shared/models/registration/draft-registration.model'; import { CustomStepComponent } from '../../components/custom-step/custom-step.component'; -import { RegistriesSelectors, UpdateDraft } from '../../store'; import { DraftRegistrationCustomStepComponent } from './draft-registration-custom-step.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { MOCK_REGISTRIES_PAGE } from '@testing/mocks/registries.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; +import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +const MOCK_DRAFT: Partial = { + id: 'draft-1', + providerId: 'prov-1', + branchedFrom: { id: 'node-1', filesLink: '/files' }, +}; +const MOCK_STEPS_DATA: Record = { 'question-1': 'answer-1' }; + describe('DraftRegistrationCustomStepComponent', () => { let component: DraftRegistrationCustomStepComponent; let fixture: ComponentFixture; let store: Store; - let mockRouter: ReturnType; - let mockActivatedRoute: ReturnType; - - const mockStepsData = { stepKey: { field: 'value' } }; - const mockDraftRegistration = { - id: 'draft-1', - providerId: 'prov-1', - branchedFrom: { id: 'proj-1', filesLink: '/project/proj-1/files/' }, - }; - - beforeEach(async () => { - mockRouter = RouterMockBuilder.create().build(); - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); - - await TestBed.configureTestingModule({ - imports: [DraftRegistrationCustomStepComponent, OSFTestingModule, MockComponent(CustomStepComponent)], + let mockRouter: RouterMockType; + + function setup( + draft: Partial | null = MOCK_DRAFT, + stepsData: Record = MOCK_STEPS_DATA + ) { + const mockRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1', step: '1' }).build(); + mockRouter = RouterMockBuilder.create().withUrl('/registries/prov-1/draft/draft-1/custom').build(); + + TestBed.configureTestingModule({ + imports: [DraftRegistrationCustomStepComponent, MockComponent(CustomStepComponent)], providers: [ - MockProvider(Router, mockRouter), - MockProvider(ActivatedRoute, mockActivatedRoute), + provideOSFCore(), + provideActivatedRouteMock(mockRoute), + provideRouterMock(mockRouter), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getStepsData, value: mockStepsData }, - { selector: RegistriesSelectors.getDraftRegistration, value: mockDraftRegistration }, + { selector: RegistriesSelectors.getStepsData, value: stepsData }, + { selector: RegistriesSelectors.getDraftRegistration, value: draft }, + { selector: RegistriesSelectors.getPagesSchema, value: [MOCK_REGISTRIES_PAGE] }, + { selector: RegistriesSelectors.getStepsState, value: { 1: { invalid: false } } }, ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(DraftRegistrationCustomStepComponent); component = fixture.componentInstance; store = TestBed.inject(Store); - (store.dispatch as jest.Mock).mockReturnValue(of(void 0)); fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should return stepsData and draftRegistration from store', () => { - expect(component.stepsData()).toEqual(mockStepsData); - expect(component.draftRegistration()).toEqual(mockDraftRegistration); - }); - - it('should compute filesLink from draftRegistration branchedFrom', () => { - expect(component.filesLink()).toBe('/project/proj-1/files/'); - }); - - it('should compute provider from draftRegistration providerId', () => { + it('should compute inputs from draft registration', () => { + setup(); + expect(component.filesLink()).toBe('/files'); expect(component.provider()).toBe('prov-1'); + expect(component.projectId()).toBe('node-1'); }); - it('should compute projectId from draftRegistration branchedFrom id', () => { - expect(component.projectId()).toBe('proj-1'); + it('should return empty strings when draftRegistration is null', () => { + setup(null, {}); + expect(component.filesLink()).toBe(''); + expect(component.provider()).toBe(''); + expect(component.projectId()).toBe(''); }); - it('should dispatch UpdateDraft with id and registration_responses payload on onUpdateAction', () => { - const attributes: Partial = { - registration_responses: { field1: 'value1' }, - }; - (store.dispatch as jest.Mock).mockClear(); - - component.onUpdateAction(attributes); - - expect(store.dispatch).toHaveBeenCalledWith(expect.any(UpdateDraft)); - const call = (store.dispatch as jest.Mock).mock.calls.find((c) => c[0] instanceof UpdateDraft); - expect(call[0].draftId).toBe('draft-1'); - expect(call[0].attributes).toEqual({ registration_responses: { registration_responses: { field1: 'value1' } } }); + it('should dispatch updateDraft with wrapped registration_responses', () => { + setup(); + component.onUpdateAction({ field1: 'value1', field2: ['a', 'b'] } as any); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateDraft('draft-1', { registration_responses: { field1: 'value1', field2: ['a', 'b'] } }) + ); }); - it('should navigate to ../metadata on onBack', () => { + it('should navigate back to metadata on onBack', () => { + setup(); component.onBack(); expect(mockRouter.navigate).toHaveBeenCalledWith( ['../', 'metadata'], @@ -101,7 +96,8 @@ describe('DraftRegistrationCustomStepComponent', () => { ); }); - it('should navigate to ../review on onNext', () => { + it('should navigate to review on onNext', () => { + setup(); component.onNext(); expect(mockRouter.navigate).toHaveBeenCalledWith( ['../', 'review'], @@ -109,34 +105,3 @@ describe('DraftRegistrationCustomStepComponent', () => { ); }); }); - -describe('DraftRegistrationCustomStepComponent when no draft registration', () => { - let component: DraftRegistrationCustomStepComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DraftRegistrationCustomStepComponent, OSFTestingModule, MockComponent(CustomStepComponent)], - providers: [ - MockProvider(Router, RouterMockBuilder.create().build()), - MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build()), - provideMockStore({ - signals: [ - { selector: RegistriesSelectors.getStepsData, value: {} }, - { selector: RegistriesSelectors.getDraftRegistration, value: null }, - ], - }), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(DraftRegistrationCustomStepComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should compute empty filesLink provider and projectId', () => { - expect(component.filesLink()).toBe(''); - expect(component.provider()).toBe(''); - expect(component.projectId()).toBe(''); - }); -}); diff --git a/src/app/features/registries/pages/justification/justification.component.spec.ts b/src/app/features/registries/pages/justification/justification.component.spec.ts index ddbee0e57..00b39b835 100644 --- a/src/app/features/registries/pages/justification/justification.component.spec.ts +++ b/src/app/features/registries/pages/justification/justification.component.spec.ts @@ -1,87 +1,240 @@ -import { MockComponents, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponents } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { NavigationEnd } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; -import { LoaderService } from '@osf/shared/services/loader.service'; +import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; +import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; +import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; + +import { ClearState, FetchSchemaBlocks, FetchSchemaResponse, RegistriesSelectors } from '../../store'; import { JustificationComponent } from './justification.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { createMockSchemaResponse } from '@testing/mocks/schema-response.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; +import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +const MOCK_SCHEMA_RESPONSE = createMockSchemaResponse('resp-1', RevisionReviewStates.RevisionInProgress); + +const MOCK_PAGES: PageSchema[] = [ + { id: 'page-1', title: 'Page One', questions: [{ id: 'q1', displayText: 'Q1', required: true, responseKey: 'q1' }] }, + { id: 'page-2', title: 'Page Two', questions: [{ id: 'q2', displayText: 'Q2', required: false, responseKey: 'q2' }] }, +]; + +interface SetupOptions { + routeParams?: Record; + routerUrl?: string; + schemaResponse?: SchemaResponse | null; + pages?: PageSchema[]; + stepsState?: Record; + revisionData?: Record; +} + describe('JustificationComponent', () => { let component: JustificationComponent; let fixture: ComponentFixture; - let mockActivatedRoute: Partial; - let mockRouter: ReturnType; - - beforeEach(async () => { - mockActivatedRoute = { - snapshot: { - firstChild: { params: { id: 'rev-1', step: '0' } } as any, - } as any, - firstChild: { snapshot: { params: { id: 'rev-1', step: '0' } } } as any, - } as Partial; - mockRouter = RouterMockBuilder.create().withUrl('/registries/revisions/rev-1/justification').build(); - - await TestBed.configureTestingModule({ - imports: [JustificationComponent, OSFTestingModule, ...MockComponents(StepperComponent, SubHeaderComponent)], + let store: Store; + let mockRouter: RouterMockType; + let routerBuilder: RouterMockBuilder; + let loaderService: LoaderServiceMock; + + function setup(options: SetupOptions = {}) { + const { + routeParams = { id: 'rev-1' }, + routerUrl = '/registries/revisions/rev-1/justification', + schemaResponse = MOCK_SCHEMA_RESPONSE, + pages = MOCK_PAGES, + stepsState = {}, + revisionData = MOCK_SCHEMA_RESPONSE.revisionResponses, + } = options; + + routerBuilder = RouterMockBuilder.create().withUrl(routerUrl); + mockRouter = routerBuilder.build(); + loaderService = new LoaderServiceMock(); + + const mockRoute = ActivatedRouteMockBuilder.create() + .withFirstChild((child) => child.withParams(routeParams)) + .build(); + + TestBed.configureTestingModule({ + imports: [JustificationComponent, ...MockComponents(StepperComponent, SubHeaderComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(Router, mockRouter), - MockProvider(LoaderService, { show: jest.fn(), hide: jest.fn() }), + provideOSFCore(), + provideActivatedRouteMock(mockRoute), + provideRouterMock(mockRouter), + provideLoaderServiceMock(loaderService), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getPagesSchema, value: [] }, - { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false, touched: false } } }, - { - selector: RegistriesSelectors.getSchemaResponse, - value: { - registrationSchemaId: 'schema-1', - revisionJustification: 'Reason', - reviewsState: 'revision_in_progress', - }, - }, - { selector: RegistriesSelectors.getSchemaResponseRevisionData, value: {} }, + { selector: RegistriesSelectors.getSchemaResponse, value: schemaResponse }, + { selector: RegistriesSelectors.getPagesSchema, value: pages }, + { selector: RegistriesSelectors.getStepsState, value: stepsState }, + { selector: RegistriesSelectors.getSchemaResponseRevisionData, value: revisionData }, ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(JustificationComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should compute steps with justification and review', () => { + it('should extract revisionId from route params', () => { + setup({ routeParams: { id: 'rev-42' } }); + expect(component.revisionId).toBe('rev-42'); + }); + + it('should default revisionId to empty string when no id param', () => { + setup({ routeParams: {} }); + expect(component.revisionId).toBe(''); + }); + + it('should build justification as first and review as last step with custom steps in between', () => { + setup(); + const steps = component.steps(); + expect(steps.length).toBe(4); + expect(steps[0]).toEqual(expect.objectContaining({ index: 0, value: 'justification', routeLink: 'justification' })); + expect(steps[1]).toEqual(expect.objectContaining({ index: 1, label: 'Page One', value: 'page-1', routeLink: '1' })); + expect(steps[2]).toEqual(expect.objectContaining({ index: 2, label: 'Page Two', value: 'page-2', routeLink: '2' })); + expect(steps[3]).toEqual( + expect.objectContaining({ index: 3, value: 'review', routeLink: 'review', invalid: false }) + ); + }); + + it('should mark justification step as invalid when revisionJustification is empty', () => { + setup({ schemaResponse: { ...MOCK_SCHEMA_RESPONSE, revisionJustification: '' } }); + const step = component.steps()[0]; + expect(step.invalid).toBe(true); + expect(step.touched).toBe(false); + }); + + it('should disable steps when reviewsState is not RevisionInProgress', () => { + setup({ schemaResponse: createMockSchemaResponse('resp-1', RevisionReviewStates.Approved) }); + const steps = component.steps(); + expect(steps[0].disabled).toBe(true); + expect(steps[1].disabled).toBe(true); + }); + + it('should apply stepsState invalid/touched to custom steps', () => { + setup({ stepsState: { 1: { invalid: true, touched: true }, 2: { invalid: false, touched: false } } }); + const steps = component.steps(); + expect(steps[1]).toEqual(expect.objectContaining({ invalid: true, touched: true })); + expect(steps[2]).toEqual(expect.objectContaining({ invalid: false, touched: false })); + }); + + it('should handle null schemaResponse gracefully', () => { + setup({ schemaResponse: null }); + const step = component.steps()[0]; + expect(step.invalid).toBe(true); + expect(step.disabled).toBe(true); + }); + + it('should produce only justification and review when no pages', () => { + setup({ pages: [] }); const steps = component.steps(); expect(steps.length).toBe(2); expect(steps[0].value).toBe('justification'); - expect(steps[1].value).toBe('review'); + expect(steps[1]).toEqual(expect.objectContaining({ index: 1, value: 'review' })); + }); + + it('should initialize currentStepIndex from route step param', () => { + setup({ routeParams: { id: 'rev-1', step: '2' } }); + expect(component.currentStepIndex()).toBe(2); + }); + + it('should default currentStepIndex to 0 when no step param', () => { + setup(); + expect(component.currentStepIndex()).toBe(0); + }); + + it('should return the step at currentStepIndex', () => { + setup(); + component.currentStepIndex.set(0); + expect(component.currentStep().value).toBe('justification'); + }); + + it('should update currentStepIndex and navigate on stepChange', () => { + setup(); + component.stepChange({ index: 1, label: 'Page One', value: 'page-1' } as any); + expect(component.currentStepIndex()).toBe(1); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/revisions/rev-1/', '1']); + }); + + it('should navigate to review route for last step', () => { + setup(); + const reviewIndex = component.steps().length - 1; + component.stepChange({ index: reviewIndex, label: 'Review', value: 'review' } as any); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/revisions/rev-1/', 'review']); + }); + + it('should update currentStepIndex on NavigationEnd', () => { + setup({ routeParams: { id: 'rev-1', step: '2' }, routerUrl: '/registries/revisions/rev-1/2' }); + routerBuilder.emit(new NavigationEnd(1, '/test', '/test')); + expect(component.currentStepIndex()).toBe(2); + }); + + it('should show loader on init', () => { + setup(); + expect(loaderService.show).toHaveBeenCalled(); + }); + + it('should dispatch FetchSchemaResponse when not already loaded', () => { + setup({ schemaResponse: null }); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSchemaResponse('rev-1')); + }); + + it('should not dispatch FetchSchemaResponse when already loaded', () => { + setup(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchSchemaResponse)); + }); + + it('should dispatch FetchSchemaBlocks when schemaResponse has registrationSchemaId', () => { + setup(); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSchemaBlocks(MOCK_SCHEMA_RESPONSE.registrationSchemaId)); + }); + + it('should dispatch clearState on destroy', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); + }); + + it('should detect review page from URL', () => { + setup({ routerUrl: '/registries/revisions/rev-1/review' }); + expect(component['isReviewPage']).toBe(true); + }); + + it('should return false for isReviewPage when not on review', () => { + setup(); + expect(component['isReviewPage']).toBe(false); }); - it('should navigate on stepChange', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); - component.stepChange({ index: 1, routeLink: '1', value: 'p1', label: 'Page 1' } as any); - expect(navSpy).toHaveBeenCalledWith(['/registries/revisions/rev-1/', 'review']); + it('should set currentStepIndex to last step on NavigationEnd when on review page without step param', () => { + setup({ routeParams: { id: 'rev-1' }, routerUrl: '/registries/revisions/rev-1/review' }); + component.currentStepIndex.set(0); + routerBuilder.emit(new NavigationEnd(2, '/review', '/review')); + expect(component.currentStepIndex()).toBe(MOCK_PAGES.length + 1); }); - it('should clear state on destroy', () => { - const actionsMock = { - clearState: jest.fn(), - getSchemaBlocks: jest.fn().mockReturnValue({ pipe: () => ({ subscribe: () => {} }) }), - } as any; - Object.defineProperty(component as any, 'actions', { value: actionsMock }); - fixture.destroy(); - expect(actionsMock.clearState).toHaveBeenCalled(); + it('should reset currentStepIndex to 0 on NavigationEnd when not on review and no step param', () => { + setup({ routeParams: { id: 'rev-1' }, routerUrl: '/registries/revisions/rev-1/justification' }); + component.currentStepIndex.set(2); + routerBuilder.emit(new NavigationEnd(2, '/justification', '/justification')); + expect(component.currentStepIndex()).toBe(0); }); }); diff --git a/src/app/features/registries/pages/justification/justification.component.ts b/src/app/features/registries/pages/justification/justification.component.ts index e196e9038..610dfd668 100644 --- a/src/app/features/registries/pages/justification/justification.component.ts +++ b/src/app/features/registries/pages/justification/justification.component.ts @@ -2,7 +2,7 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { filter, tap } from 'rxjs'; +import { filter } from 'rxjs'; import { ChangeDetectionStrategy, @@ -12,7 +12,6 @@ import { effect, inject, OnDestroy, - Signal, signal, untracked, } from '@angular/core'; @@ -33,21 +32,14 @@ import { ClearState, FetchSchemaBlocks, FetchSchemaResponse, RegistriesSelectors templateUrl: './justification.component.html', styleUrl: './justification.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [TranslateService], }) export class JustificationComponent implements OnDestroy { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); - private readonly loaderService = inject(LoaderService); private readonly translateService = inject(TranslateService); - readonly pages = select(RegistriesSelectors.getPagesSchema); - readonly stepsState = select(RegistriesSelectors.getStepsState); - readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); - readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); - private readonly actions = createDispatchMap({ getSchemaBlocks: FetchSchemaBlocks, clearState: ClearState, @@ -55,61 +47,79 @@ export class JustificationComponent implements OnDestroy { updateStepState: UpdateStepState, }); + readonly pages = select(RegistriesSelectors.getPagesSchema); + readonly stepsState = select(RegistriesSelectors.getStepsState); + readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); + readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); + + readonly revisionId = this.route.snapshot.firstChild?.params['id'] || ''; + get isReviewPage(): boolean { return this.router.url.includes('/review'); } - reviewStep!: StepOption; - justificationStep!: StepOption; - revisionId = this.route.snapshot.firstChild?.params['id'] || ''; + readonly steps = computed(() => { + const response = this.schemaResponse(); + const isJustificationValid = !!response?.revisionJustification; + const isDisabled = response?.reviewsState !== RevisionReviewStates.RevisionInProgress; + const stepState = this.stepsState(); + const pages = this.pages(); - steps: Signal = computed(() => { - const isJustificationValid = !!this.schemaResponse()?.revisionJustification; - this.justificationStep = { + const justificationStep: StepOption = { index: 0, value: 'justification', label: this.translateService.instant('registries.justification.step'), invalid: !isJustificationValid, touched: isJustificationValid, routeLink: 'justification', - disabled: this.schemaResponse()?.reviewsState !== RevisionReviewStates.RevisionInProgress, + disabled: isDisabled, }; - this.reviewStep = { - index: 1, + const customSteps: StepOption[] = pages.map((page, index) => ({ + index: index + 1, + label: page.title, + value: page.id, + routeLink: `${index + 1}`, + invalid: stepState?.[index + 1]?.invalid || false, + touched: stepState?.[index + 1]?.touched || false, + disabled: isDisabled, + })); + + const reviewStep: StepOption = { + index: customSteps.length + 1, value: 'review', label: this.translateService.instant('registries.review.step'), invalid: false, routeLink: 'review', }; - const stepState = this.stepsState(); - const customSteps = this.pages().map((page, index) => { - return { - index: index + 1, - label: page.title, - value: page.id, - routeLink: `${index + 1}`, - invalid: stepState?.[index + 1]?.invalid || false, - touched: stepState?.[index + 1]?.touched || false, - disabled: this.schemaResponse()?.reviewsState !== RevisionReviewStates.RevisionInProgress, - }; - }); - return [ - { ...this.justificationStep }, - ...customSteps, - { ...this.reviewStep, index: customSteps.length + 1, invalid: false }, - ]; + + return [justificationStep, ...customSteps, reviewStep]; }); currentStepIndex = signal( this.route.snapshot.firstChild?.params['step'] ? +this.route.snapshot.firstChild?.params['step'] : 0 ); - currentStep = computed(() => { - return this.steps()[this.currentStepIndex()]; - }); + currentStep = computed(() => this.steps()[this.currentStepIndex()]); constructor() { + this.initRouterListener(); + this.initDataFetching(); + this.initReviewPageSync(); + this.initStepValidation(); + } + + ngOnDestroy(): void { + this.actions.clearState(); + } + + stepChange(step: StepOption): void { + this.currentStepIndex.set(step.index); + const pageLink = this.steps()[step.index].routeLink; + this.router.navigate([`/registries/revisions/${this.revisionId}/`, pageLink]); + } + + private initRouterListener(): void { this.router.events .pipe( takeUntilDestroyed(this.destroyRef), @@ -120,47 +130,56 @@ export class JustificationComponent implements OnDestroy { if (step) { this.currentStepIndex.set(+step); } else if (this.isReviewPage) { - const reviewStepIndex = this.pages().length + 1; - this.currentStepIndex.set(reviewStepIndex); + this.currentStepIndex.set(this.pages().length + 1); } else { this.currentStepIndex.set(0); } }); + } + private initDataFetching(): void { this.loaderService.show(); + if (!this.schemaResponse()) { this.actions.getSchemaResponse(this.revisionId); } effect(() => { const registrationSchemaId = this.schemaResponse()?.registrationSchemaId; + if (registrationSchemaId) { - this.actions - .getSchemaBlocks(registrationSchemaId) - .pipe(tap(() => this.loaderService.hide())) - .subscribe(); + this.actions.getSchemaBlocks(registrationSchemaId).subscribe(() => this.loaderService.hide()); } }); + } + private initReviewPageSync(): void { effect(() => { const reviewStepIndex = this.pages().length + 1; + if (this.isReviewPage) { this.currentStepIndex.set(reviewStepIndex); } }); + } + private initStepValidation(): void { effect(() => { + const currentIndex = this.currentStepIndex(); + const pages = this.pages(); + const revisionData = this.schemaResponseRevisionData(); const stepState = untracked(() => this.stepsState()); - if (this.currentStepIndex() > 0) { + if (currentIndex > 0) { this.actions.updateStepState('0', true, stepState?.[0]?.touched || false); } - if (this.pages().length && this.currentStepIndex() > 0 && this.schemaResponseRevisionData()) { - for (let i = 1; i < this.currentStepIndex(); i++) { - const pageStep = this.pages()[i - 1]; + + if (pages.length && currentIndex > 0 && revisionData) { + for (let i = 1; i < currentIndex; i++) { + const pageStep = pages[i - 1]; const isStepInvalid = pageStep?.questions?.some((question) => { - const questionData = this.schemaResponseRevisionData()[question.responseKey!]; + const questionData = revisionData[question.responseKey!]; return question.required && (Array.isArray(questionData) ? !questionData.length : !questionData); }) || false; this.actions.updateStepState(i.toString(), isStepInvalid, stepState?.[i]?.touched || false); @@ -168,14 +187,4 @@ export class JustificationComponent implements OnDestroy { } }); } - - stepChange(step: StepOption): void { - this.currentStepIndex.set(step.index); - const pageLink = this.steps()[step.index].routeLink; - this.router.navigate([`/registries/revisions/${this.revisionId}/`, pageLink]); - } - - ngOnDestroy(): void { - this.actions.clearState(); - } } diff --git a/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts b/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts index d4f40e53f..b82eba951 100644 --- a/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts +++ b/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts @@ -28,24 +28,10 @@ describe('MyRegistrationsRedirectComponent', () => { expect(component).toBeTruthy(); }); - it('should be an instance of MyRegistrationsRedirectComponent', () => { - expect(component).toBeInstanceOf(MyRegistrationsRedirectComponent); - }); - it('should navigate to /my-registrations on component creation', () => { expect(router.navigate).toHaveBeenCalledWith(['/my-registrations'], { queryParamsHandling: 'preserve', replaceUrl: true, }); }); - - it('should preserve query parameters during navigation', () => { - const navigationOptions = router.navigate.mock.calls[0][1]; - expect(navigationOptions?.queryParamsHandling).toBe('preserve'); - }); - - it('should replace the current URL in browser history', () => { - const navigationOptions = router.navigate.mock.calls[0][1]; - expect(navigationOptions?.replaceUrl).toBe(true); - }); }); diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.html b/src/app/features/registries/pages/my-registrations/my-registrations.component.html index d9197ccaa..45a6b4e5f 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.html +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.html @@ -10,7 +10,7 @@
- + @if (!isMobile()) { @for (tab of tabOptions; track tab.value) { diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts b/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts index 5f1c0f4e4..b5bf6b208 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts @@ -1,9 +1,9 @@ -import { MockComponents, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { of } from 'rxjs'; +import { MockComponents } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { UserSelectors } from '@core/store/user'; import { RegistrationTab } from '@osf/features/registries/enums'; @@ -15,35 +15,40 @@ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { DeleteDraft, FetchDraftRegistrations, FetchSubmittedRegistrations } from '../../store'; + import { MyRegistrationsComponent } from './my-registrations.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { MockCustomConfirmationServiceProvider } from '@testing/mocks/custom-confirmation.service.mock'; +import { provideOSFCore, provideOSFToast } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; +import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('MyRegistrationsComponent', () => { let component: MyRegistrationsComponent; let fixture: ComponentFixture; - let mockRouter: ReturnType; - let mockActivatedRoute: Partial; + let store: Store; + let mockRoute: ReturnType; + let mockRouter: RouterMockType; let customConfirmationService: jest.Mocked; let toastService: jest.Mocked; - beforeEach(async () => { + function setup(queryParams: Record = {}) { mockRouter = RouterMockBuilder.create().withUrl('/registries/me').build(); - mockActivatedRoute = { snapshot: { queryParams: {} } } as any; + mockRoute = ActivatedRouteMockBuilder.create().withQueryParams(queryParams).build(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ MyRegistrationsComponent, - OSFTestingModule, ...MockComponents(SubHeaderComponent, SelectComponent, RegistrationCardComponent, CustomPaginatorComponent), ], providers: [ - { provide: Router, useValue: mockRouter }, - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - MockProvider(CustomConfirmationService, { confirmDelete: jest.fn() }), - MockProvider(ToastService, { showSuccess: jest.fn(), showWarn: jest.fn(), showError: jest.fn() }), + provideOSFCore(), + provideRouterMock(mockRouter), + provideActivatedRouteMock(mockRoute), + MockCustomConfirmationServiceProvider, + provideOSFToast(), provideMockStore({ signals: [ { selector: RegistriesSelectors.getDraftRegistrations, value: [] }, @@ -56,130 +61,109 @@ describe('MyRegistrationsComponent', () => { ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(MyRegistrationsComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); customConfirmationService = TestBed.inject(CustomConfirmationService) as jest.Mocked; toastService = TestBed.inject(ToastService) as jest.Mocked; fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should default to submitted tab when no query param', () => { + it('should default to submitted tab and fetch submitted registrations', () => { + setup(); expect(component.selectedTab()).toBe(RegistrationTab.Submitted); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSubmittedRegistrations()); }); - it('should switch to drafts tab when query param is drafts', () => { - (mockActivatedRoute.snapshot as any).queryParams = { tab: 'drafts' }; - - fixture = TestBed.createComponent(MyRegistrationsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - + it('should switch to drafts tab from query param and fetch drafts', () => { + setup({ tab: 'drafts' }); expect(component.selectedTab()).toBe(RegistrationTab.Drafts); + expect(store.dispatch).toHaveBeenCalledWith(new FetchDraftRegistrations()); }); - it('should switch to submitted tab when query param is submitted', () => { - (mockActivatedRoute.snapshot as any).queryParams = { tab: 'submitted' }; - - fixture = TestBed.createComponent(MyRegistrationsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - - expect(component.selectedTab()).toBe(RegistrationTab.Submitted); - }); - - it('should handle tab change and update query params', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const navigateSpy = jest.spyOn(mockRouter, 'navigate'); + it('should change tab to drafts, reset pagination, fetch data, and update query params', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + (mockRouter.navigate as jest.Mock).mockClear(); component.onTabChange(RegistrationTab.Drafts); expect(component.selectedTab()).toBe(RegistrationTab.Drafts); expect(component.draftFirst).toBe(0); - expect(actionsMock.getDraftRegistrations).toHaveBeenCalledWith(); - expect(navigateSpy).toHaveBeenCalledWith([], { - relativeTo: mockActivatedRoute, + expect(store.dispatch).toHaveBeenCalledWith(new FetchDraftRegistrations()); + expect(mockRouter.navigate).toHaveBeenCalledWith([], { + relativeTo: TestBed.inject(ActivatedRoute), queryParams: { tab: 'drafts' }, queryParamsHandling: 'merge', }); }); - it('should handle tab change to submitted and update query params', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const navigateSpy = jest.spyOn(mockRouter, 'navigate'); + it('should change tab to submitted, reset pagination, fetch data, and update query params', () => { + setup(); + component.onTabChange(RegistrationTab.Drafts); + (store.dispatch as jest.Mock).mockClear(); + (mockRouter.navigate as jest.Mock).mockClear(); component.onTabChange(RegistrationTab.Submitted); expect(component.selectedTab()).toBe(RegistrationTab.Submitted); expect(component.submittedFirst).toBe(0); - expect(actionsMock.getSubmittedRegistrations).toHaveBeenCalledWith(); - expect(navigateSpy).toHaveBeenCalledWith([], { - relativeTo: mockActivatedRoute, + expect(store.dispatch).toHaveBeenCalledWith(new FetchSubmittedRegistrations()); + expect(mockRouter.navigate).toHaveBeenCalledWith([], { + relativeTo: TestBed.inject(ActivatedRoute), queryParams: { tab: 'submitted' }, queryParamsHandling: 'merge', }); }); - it('should not process tab change if tab is not a number', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); + it('should ignore invalid tab values', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); const initialTab = component.selectedTab(); - component.onTabChange('invalid' as any); + component.onTabChange('invalid'); + component.onTabChange(0); expect(component.selectedTab()).toBe(initialTab); - expect(actionsMock.getDraftRegistrations).not.toHaveBeenCalled(); - expect(actionsMock.getSubmittedRegistrations).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); }); it('should navigate to create registration page', () => { - const navSpy = jest.spyOn(mockRouter, 'navigate'); + setup(); component.goToCreateRegistration(); - expect(navSpy).toHaveBeenLastCalledWith(['/registries', 'osf', 'new']); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries', 'osf', 'new']); }); it('should handle drafts pagination', () => { - const actionsMock = { getDraftRegistrations: jest.fn() } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - component.onDraftsPageChange({ page: 2, first: 20 } as any); - expect(actionsMock.getDraftRegistrations).toHaveBeenCalledWith(3); + setup(); + (store.dispatch as jest.Mock).mockClear(); + + component.onDraftsPageChange({ page: 2, first: 20 }); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchDraftRegistrations(3)); expect(component.draftFirst).toBe(20); }); it('should handle submitted pagination', () => { - const actionsMock = { getSubmittedRegistrations: jest.fn() } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - component.onSubmittedPageChange({ page: 1, first: 10 } as any); - expect(actionsMock.getSubmittedRegistrations).toHaveBeenCalledWith(2); + setup(); + (store.dispatch as jest.Mock).mockClear(); + + component.onSubmittedPageChange({ page: 1, first: 10 }); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchSubmittedRegistrations(2)); expect(component.submittedFirst).toBe(10); }); it('should delete draft after confirmation', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(() => of({})), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); + setup(); + (store.dispatch as jest.Mock).mockClear(); customConfirmationService.confirmDelete.mockImplementation(({ onConfirm }) => { onConfirm(); }); @@ -191,53 +175,21 @@ describe('MyRegistrationsComponent', () => { messageKey: 'registries.confirmDeleteDraft', onConfirm: expect.any(Function), }); - expect(actionsMock.deleteDraft).toHaveBeenCalledWith('draft-123'); - expect(actionsMock.getDraftRegistrations).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteDraft('draft-123')); + expect(store.dispatch).toHaveBeenCalledWith(new FetchDraftRegistrations()); expect(toastService.showSuccess).toHaveBeenCalledWith('registries.successDeleteDraft'); }); it('should not delete draft if confirmation is cancelled', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); + setup(); + (store.dispatch as jest.Mock).mockClear(); + toastService.showSuccess.mockClear(); customConfirmationService.confirmDelete.mockImplementation(() => {}); component.onDeleteDraft('draft-123'); expect(customConfirmationService.confirmDelete).toHaveBeenCalled(); - expect(actionsMock.deleteDraft).not.toHaveBeenCalled(); - expect(actionsMock.getDraftRegistrations).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); expect(toastService.showSuccess).not.toHaveBeenCalled(); }); - - it('should reset draftFirst when switching to drafts tab', () => { - component.draftFirst = 20; - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - - component.onTabChange(RegistrationTab.Drafts); - - expect(component.draftFirst).toBe(0); - }); - - it('should reset submittedFirst when switching to submitted tab', () => { - component.submittedFirst = 20; - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - - component.onTabChange(RegistrationTab.Submitted); - - expect(component.submittedFirst).toBe(0); - }); }); diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.ts b/src/app/features/registries/pages/my-registrations/my-registrations.component.ts index 106db16e9..95179b7b7 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.ts +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.ts @@ -10,7 +10,6 @@ import { TabsModule } from 'primeng/tabs'; import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -30,17 +29,16 @@ import { DeleteDraft, FetchDraftRegistrations, FetchSubmittedRegistrations, Regi @Component({ selector: 'osf-my-registrations', imports: [ - SubHeaderComponent, - TranslatePipe, - TabsModule, - FormsModule, - SelectComponent, - RegistrationCardComponent, - CustomPaginatorComponent, - Skeleton, Button, + Skeleton, + TabsModule, RouterLink, NgTemplateOutlet, + CustomPaginatorComponent, + RegistrationCardComponent, + SelectComponent, + SubHeaderComponent, + TranslatePipe, ], templateUrl: './my-registrations.component.html', styleUrl: './my-registrations.component.scss', @@ -82,26 +80,28 @@ export class MyRegistrationsComponent { constructor() { const initialTab = this.route.snapshot.queryParams['tab']; - const selectedTab = initialTab == 'drafts' ? RegistrationTab.Drafts : RegistrationTab.Submitted; + const selectedTab = initialTab === RegistrationTab.Drafts ? RegistrationTab.Drafts : RegistrationTab.Submitted; this.onTabChange(selectedTab); } onTabChange(tab: Primitive): void { - if (typeof tab !== 'number') { + if (typeof tab !== 'string' || !Object.values(RegistrationTab).includes(tab as RegistrationTab)) { return; } - this.selectedTab.set(tab); - this.loadTabData(tab); + const validTab = tab as RegistrationTab; + + this.selectedTab.set(validTab); + this.loadTabData(validTab); this.router.navigate([], { relativeTo: this.route, - queryParams: { tab: tab === RegistrationTab.Drafts ? 'drafts' : 'submitted' }, + queryParams: { tab }, queryParamsHandling: 'merge', }); } - private loadTabData(tab: number): void { + private loadTabData(tab: RegistrationTab): void { if (tab === RegistrationTab.Drafts) { this.draftFirst = 0; this.actions.getDraftRegistrations(); diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts b/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts index cf4780553..7520a2198 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts @@ -1,36 +1,39 @@ +import { Store } from '@ngxs/store'; + import { MockComponents } from 'ng-mocks'; +import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Router } from '@angular/router'; import { ScheduledBannerComponent } from '@core/components/osf-banners/scheduled-banner/scheduled-banner.component'; +import { ClearCurrentProvider } from '@core/store/provider'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { ResourceCardComponent } from '@osf/shared/components/resource-card/resource-card.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; -import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; +import { ClearRegistryProvider, GetRegistryProvider } from '@osf/shared/stores/registration-provider'; import { RegistryServicesComponent } from '../../components/registry-services/registry-services.component'; -import { RegistriesSelectors } from '../../store'; +import { GetRegistries, RegistriesSelectors } from '../../store'; import { RegistriesLandingComponent } from './registries-landing.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesLandingComponent', () => { let component: RegistriesLandingComponent; let fixture: ComponentFixture; - let mockRouter: ReturnType; + let store: Store; + let mockRouter: RouterMockType; - beforeEach(async () => { + beforeEach(() => { mockRouter = RouterMockBuilder.create().withUrl('/registries').build(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ RegistriesLandingComponent, - OSFTestingModule, ...MockComponents( SearchInputComponent, RegistryServicesComponent, @@ -41,20 +44,21 @@ describe('RegistriesLandingComponent', () => { ), ], providers: [ - { provide: Router, useValue: mockRouter }, + provideOSFCore(), + provideRouterMock(mockRouter), + { provide: PLATFORM_ID, useValue: 'browser' }, provideMockStore({ signals: [ - { selector: RegistrationProviderSelectors.getBrandedProvider, value: null }, - { selector: RegistrationProviderSelectors.isBrandedProviderLoading, value: false }, { selector: RegistriesSelectors.getRegistries, value: [] }, { selector: RegistriesSelectors.isRegistriesLoading, value: false }, ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(RegistriesLandingComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.detectChanges(); }); @@ -62,51 +66,31 @@ describe('RegistriesLandingComponent', () => { expect(component).toBeTruthy(); }); - it('should dispatch get registries and provider on init', () => { - const actionsMock = { - getRegistries: jest.fn(), - getProvider: jest.fn(), - clearCurrentProvider: jest.fn(), - clearRegistryProvider: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - - component.ngOnInit(); - - expect(actionsMock.getRegistries).toHaveBeenCalled(); - expect(actionsMock.getProvider).toHaveBeenCalledWith(component.defaultProvider); + it('should dispatch getRegistries and getProvider on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistries()); + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistryProvider(component.defaultProvider)); }); - it('should clear providers on destroy', () => { - const actionsMock = { - getRegistries: jest.fn(), - getProvider: jest.fn(), - clearCurrentProvider: jest.fn(), - clearRegistryProvider: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - + it('should dispatch clear actions on destroy', () => { + (store.dispatch as jest.Mock).mockClear(); fixture.destroy(); - expect(actionsMock.clearCurrentProvider).toHaveBeenCalled(); - expect(actionsMock.clearRegistryProvider).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new ClearCurrentProvider()); + expect(store.dispatch).toHaveBeenCalledWith(new ClearRegistryProvider()); }); it('should navigate to search with value', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); component.searchControl.setValue('abc'); component.redirectToSearchPageWithValue(); - expect(navSpy).toHaveBeenCalledWith(['/search'], { queryParams: { search: 'abc', tab: 3 } }); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/search'], { queryParams: { search: 'abc', tab: 3 } }); }); it('should navigate to search registrations tab', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); component.redirectToSearchPageRegistrations(); - expect(navSpy).toHaveBeenCalledWith(['/search'], { queryParams: { tab: 3 } }); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/search'], { queryParams: { tab: 3 } }); }); it('should navigate to create page', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); component.goToCreateRegistration(); - expect(navSpy).toHaveBeenCalledWith(['/registries/osf/new']); + expect(mockRouter.navigate).toHaveBeenCalledWith([`/registries/${component.defaultProvider}/new`]); }); }); diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.ts b/src/app/features/registries/pages/registries-landing/registries-landing.component.ts index 1aa9c22c8..917caf2fc 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.ts +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.ts @@ -18,11 +18,7 @@ import { SearchInputComponent } from '@osf/shared/components/search-input/search import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { normalizeQuotes } from '@osf/shared/helpers/normalize-quotes'; -import { - ClearRegistryProvider, - GetRegistryProvider, - RegistrationProviderSelectors, -} from '@osf/shared/stores/registration-provider'; +import { ClearRegistryProvider, GetRegistryProvider } from '@osf/shared/stores/registration-provider'; import { RegistryServicesComponent } from '../../components/registry-services/registry-services.component'; import { GetRegistries, RegistriesSelectors } from '../../store'; @@ -44,10 +40,9 @@ import { GetRegistries, RegistriesSelectors } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistriesLandingComponent implements OnInit, OnDestroy { - private router = inject(Router); + private readonly router = inject(Router); private readonly environment = inject(ENVIRONMENT); - private readonly platformId = inject(PLATFORM_ID); - private readonly isBrowser = isPlatformBrowser(this.platformId); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); private actions = createDispatchMap({ getRegistries: GetRegistries, @@ -56,8 +51,6 @@ export class RegistriesLandingComponent implements OnInit, OnDestroy { clearRegistryProvider: ClearRegistryProvider, }); - provider = select(RegistrationProviderSelectors.getBrandedProvider); - isProviderLoading = select(RegistrationProviderSelectors.isBrandedProviderLoading); registries = select(RegistriesSelectors.getRegistries); isRegistriesLoading = select(RegistriesSelectors.isRegistriesLoading); diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts index 6498fed94..6f53ec22f 100644 --- a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts @@ -1,67 +1,115 @@ -import { MockComponents, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; +import { MockComponents } from 'ng-mocks'; + +import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { RegistryProviderHeroComponent } from '@osf/features/registries/components/registry-provider-hero/registry-provider-hero.component'; +import { ClearCurrentProvider } from '@core/store/provider'; import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; -import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { RegistryProviderDetails } from '@osf/shared/models/provider/registry-provider.model'; +import { SetDefaultFilterValue, SetResourceType } from '@osf/shared/stores/global-search'; +import { + ClearRegistryProvider, + GetRegistryProvider, + RegistrationProviderSelectors, +} from '@osf/shared/stores/registration-provider'; + +import { RegistryProviderHeroComponent } from '../../components/registry-provider-hero/registry-provider-hero.component'; import { RegistriesProviderSearchComponent } from './registries-provider-search.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +const MOCK_PROVIDER: RegistryProviderDetails = { + id: 'provider-1', + name: 'Test Provider', + descriptionHtml: '', + permissions: [], + brand: null, + iri: 'http://iri.example.com', + reviewsWorkflow: 'pre-moderation', +}; + describe('RegistriesProviderSearchComponent', () => { let component: RegistriesProviderSearchComponent; let fixture: ComponentFixture; + let store: Store; - beforeEach(async () => { - const routeMock = ActivatedRouteMockBuilder.create().withParams({ name: 'osf' }).build(); + const PROVIDER_ID = 'provider-1'; - await TestBed.configureTestingModule({ + function setup(params: Record = { providerId: PROVIDER_ID }, platformId = 'browser') { + const mockRoute = ActivatedRouteMockBuilder.create().withParams(params).build(); + + TestBed.configureTestingModule({ imports: [ RegistriesProviderSearchComponent, - OSFTestingModule, - ...MockComponents(GlobalSearchComponent, RegistryProviderHeroComponent), + ...MockComponents(RegistryProviderHeroComponent, GlobalSearchComponent), ], providers: [ - { provide: ActivatedRoute, useValue: routeMock }, - MockProvider(CustomDialogService, { open: jest.fn() }), + provideOSFCore(), + provideActivatedRouteMock(mockRoute), + { provide: PLATFORM_ID, useValue: platformId }, provideMockStore({ signals: [ - { selector: RegistrationProviderSelectors.getBrandedProvider, value: { iri: 'http://iri/provider' } }, + { selector: RegistrationProviderSelectors.getBrandedProvider, value: MOCK_PROVIDER }, { selector: RegistrationProviderSelectors.isBrandedProviderLoading, value: false }, ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(RegistriesProviderSearchComponent); component = fixture.componentInstance; - }); + store = TestBed.inject(Store); + fixture.detectChanges(); + } it('should create', () => { - fixture.detectChanges(); + setup(); expect(component).toBeTruthy(); }); - it('should clear providers on destroy', () => { - fixture.detectChanges(); + it('should fetch provider and initialize search filters on init', () => { + setup(); + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistryProvider(PROVIDER_ID)); + expect(store.dispatch).toHaveBeenCalledWith(new SetDefaultFilterValue('publisher', MOCK_PROVIDER.iri)); + expect(store.dispatch).toHaveBeenCalledWith(new SetResourceType(ResourceType.Registration)); + expect(component.defaultSearchFiltersInitialized()).toBe(true); + }); + + it('should initialize searchControl with empty string', () => { + setup(); + expect(component.searchControl.value).toBe(''); + }); + + it('should expose provider and isProviderLoading from store', () => { + setup(); + expect(component.provider()).toEqual(MOCK_PROVIDER); + expect(component.isProviderLoading()).toBe(false); + }); + + it('should dispatch clear actions on destroy in browser', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(new ClearCurrentProvider()); + expect(store.dispatch).toHaveBeenCalledWith(new ClearRegistryProvider()); + }); + + it('should not fetch provider or initialize filters when providerId is missing', () => { + setup({}); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetRegistryProvider)); + expect(component.defaultSearchFiltersInitialized()).toBe(false); + }); - const actionsMock = { - getProvider: jest.fn(), - setDefaultFilterValue: jest.fn(), - setResourceType: jest.fn(), - clearCurrentProvider: jest.fn(), - clearRegistryProvider: jest.fn(), - } as any; - Object.defineProperty(component as any, 'actions', { value: actionsMock }); - - fixture.destroy(); - expect(actionsMock.clearCurrentProvider).toHaveBeenCalled(); - expect(actionsMock.clearRegistryProvider).toHaveBeenCalled(); + it('should not dispatch clear actions on destroy on server', () => { + setup({}, 'server'); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts index 6411524f3..86b056485 100644 --- a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts +++ b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts @@ -1,33 +1,35 @@ -import { MockComponents, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponents } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; import { CustomStepComponent } from '../../components/custom-step/custom-step.component'; -import { RegistriesSelectors } from '../../store'; +import { RegistriesSelectors, UpdateSchemaResponse } from '../../store'; import { RevisionsCustomStepComponent } from './revisions-custom-step.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; +import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RevisionsCustomStepComponent', () => { let component: RevisionsCustomStepComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; + let store: Store; + let mockRouter: RouterMockType; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1', step: '1' }).build(); + beforeEach(() => { + const mockRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1', step: '1' }).build(); mockRouter = RouterMockBuilder.create().withUrl('/registries/revisions/rev-1/1').build(); - await TestBed.configureTestingModule({ - imports: [RevisionsCustomStepComponent, OSFTestingModule, MockComponents(CustomStepComponent)], + TestBed.configureTestingModule({ + imports: [RevisionsCustomStepComponent, MockComponents(CustomStepComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(Router, mockRouter), + provideOSFCore(), + provideActivatedRouteMock(mockRoute), + provideRouterMock(mockRouter), provideMockStore({ signals: [ { @@ -43,10 +45,11 @@ describe('RevisionsCustomStepComponent', () => { ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(RevisionsCustomStepComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.detectChanges(); }); @@ -62,21 +65,23 @@ describe('RevisionsCustomStepComponent', () => { }); it('should dispatch updateRevision on onUpdateAction', () => { - const actionsMock = { updateRevision: jest.fn() } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); component.onUpdateAction({ x: 2 }); - expect(actionsMock.updateRevision).toHaveBeenCalledWith('rev-1', 'because', { x: 2 }); + expect(store.dispatch).toHaveBeenCalledWith(new UpdateSchemaResponse('rev-1', 'because', { x: 2 })); }); it('should navigate back to justification on onBack', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); component.onBack(); - expect(navSpy).toHaveBeenCalledWith(['../', 'justification'], { relativeTo: TestBed.inject(ActivatedRoute) }); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 'justification'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); }); it('should navigate to review on onNext', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); component.onNext(); - expect(navSpy).toHaveBeenCalledWith(['../', 'review'], { relativeTo: TestBed.inject(ActivatedRoute) }); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 'review'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); }); }); diff --git a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts index e59a55ef7..73b1a1c83 100644 --- a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts +++ b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts @@ -14,31 +14,18 @@ import { RegistriesSelectors, UpdateSchemaResponse } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class RevisionsCustomStepComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - actions = createDispatchMap({ - updateRevision: UpdateSchemaResponse, - }); - - filesLink = computed(() => { - return this.schemaResponse()?.filesLink || ' '; - }); - - provider = computed(() => { - return this.schemaResponse()?.registrationId || ''; - }); - - projectId = computed(() => { - return this.schemaResponse()?.registrationId || ''; - }); - - stepsData = computed(() => { - const schemaResponse = this.schemaResponse(); - return schemaResponse?.revisionResponses || {}; - }); + actions = createDispatchMap({ updateRevision: UpdateSchemaResponse }); + + filesLink = computed(() => this.schemaResponse()?.filesLink || ' '); + provider = computed(() => this.schemaResponse()?.registrationId || ''); + projectId = computed(() => this.schemaResponse()?.registrationId || ''); + stepsData = computed(() => this.schemaResponse()?.revisionResponses || {}); onUpdateAction(data: Record): void { const id: string = this.route.snapshot.params['id'] || ''; diff --git a/src/testing/mocks/dynamic-dialog-ref.mock.ts b/src/testing/mocks/dynamic-dialog-ref.mock.ts index 091508d9e..d503e736d 100644 --- a/src/testing/mocks/dynamic-dialog-ref.mock.ts +++ b/src/testing/mocks/dynamic-dialog-ref.mock.ts @@ -1,8 +1,21 @@ import { DynamicDialogRef } from 'primeng/dynamicdialog'; +import { Subject } from 'rxjs'; + export const DynamicDialogRefMock = { provide: DynamicDialogRef, useValue: { close: jest.fn(), }, }; + +export function provideDynamicDialogRefMock() { + return { + provide: DynamicDialogRef, + useFactory: () => ({ + close: jest.fn(), + destroy: jest.fn(), + onClose: new Subject(), + }), + }; +} diff --git a/src/testing/mocks/registries.mock.ts b/src/testing/mocks/registries.mock.ts index fb5debaa8..bd3a5a998 100644 --- a/src/testing/mocks/registries.mock.ts +++ b/src/testing/mocks/registries.mock.ts @@ -1,25 +1,45 @@ import { FieldType } from '@osf/shared/enums/field-type.enum'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { DraftRegistrationModel } from '@osf/shared/models/registration/draft-registration.model'; +import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; +import { ProviderSchema } from '@osf/shared/models/registration/provider-schema.model'; -export const MOCK_REGISTRIES_PAGE = { +export const MOCK_REGISTRIES_PAGE: PageSchema = { id: 'page-1', title: 'Page 1', questions: [ - { responseKey: 'field1', fieldType: FieldType.Text, required: true }, - { responseKey: 'field2', fieldType: FieldType.Text, required: false }, + { id: 'q1', displayText: 'Field 1', responseKey: 'field1', fieldType: FieldType.Text, required: true }, + { id: 'q2', displayText: 'Field 2', responseKey: 'field2', fieldType: FieldType.Text, required: false }, ], -} as any; +}; -export const MOCK_STEPS_DATA = { field1: 'value1', field2: 'value2' } as any; +export const MOCK_REGISTRIES_PAGE_WITH_SECTIONS: PageSchema = { + id: 'page-2', + title: 'Page 2', + questions: [], + sections: [ + { + id: 'sec-1', + title: 'Section 1', + questions: [ + { id: 'q3', displayText: 'Field 3', responseKey: 'field3', fieldType: FieldType.Text, required: true }, + ], + }, + ], +}; + +export const MOCK_STEPS_DATA: Record = { field1: 'value1', field2: 'value2' }; -export const MOCK_PAGES_SCHEMA = [MOCK_REGISTRIES_PAGE]; +export const MOCK_PAGES_SCHEMA: PageSchema[] = [MOCK_REGISTRIES_PAGE]; -export const MOCK_DRAFT_REGISTRATION = { +export const MOCK_DRAFT_REGISTRATION: Partial = { id: 'draft-1', title: ' My Title ', description: ' Description ', - license: { id: 'mit' }, + license: { id: 'mit', options: null }, providerId: 'osf', - currentUserPermissions: ['admin'], -} as any; + currentUserPermissions: [UserPermissions.Admin], + registrationSchemaId: 'schema-1', +}; -export const MOCK_PROVIDER_SCHEMAS = [{ id: 'schema-1' }] as any; +export const MOCK_PROVIDER_SCHEMAS: ProviderSchema[] = [{ id: 'schema-1', name: 'Schema 1' }]; diff --git a/src/testing/osf.testing.provider.ts b/src/testing/osf.testing.provider.ts new file mode 100644 index 000000000..f3710e33a --- /dev/null +++ b/src/testing/osf.testing.provider.ts @@ -0,0 +1,48 @@ +import { TranslateModule } from '@ngx-translate/core'; + +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { importProvidersFrom } from '@angular/core'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; + +import { provideDynamicDialogRefMock } from './mocks/dynamic-dialog-ref.mock'; +import { EnvironmentTokenMock } from './mocks/environment.token.mock'; +import { ToastServiceMock } from './mocks/toast.service.mock'; +import { TranslationServiceMock } from './mocks/translation.service.mock'; +import { provideActivatedRouteMock } from './providers/route-provider.mock'; +import { provideRouterMock } from './providers/router-provider.mock'; + +export function provideOSFCore() { + return [ + provideNoopAnimations(), + importProvidersFrom(TranslateModule.forRoot()), + TranslationServiceMock, + EnvironmentTokenMock, + ]; +} + +export function provideOSFHttp() { + return [provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()]; +} + +export function provideOSFRouting() { + return [provideRouterMock(), provideActivatedRouteMock()]; +} + +export function provideOSFDialog() { + return [provideDynamicDialogRefMock()]; +} + +export function provideOSFToast() { + return [ToastServiceMock]; +} + +export function provideOSFTesting() { + return [ + ...provideOSFCore(), + ...provideOSFHttp(), + ...provideOSFRouting(), + ...provideOSFDialog(), + ...provideOSFToast(), + ]; +} diff --git a/src/testing/providers/loader-service.mock.ts b/src/testing/providers/loader-service.mock.ts index 3a76f2822..eb7d002dd 100644 --- a/src/testing/providers/loader-service.mock.ts +++ b/src/testing/providers/loader-service.mock.ts @@ -1,5 +1,7 @@ import { signal } from '@angular/core'; +import { LoaderService } from '@osf/shared/services/loader.service'; + export class LoaderServiceMock { private _isLoading = signal(false); readonly isLoading = this._isLoading.asReadonly(); @@ -7,3 +9,10 @@ export class LoaderServiceMock { show = jest.fn(() => this._isLoading.set(true)); hide = jest.fn(() => this._isLoading.set(false)); } + +export function provideLoaderServiceMock(mock?: LoaderServiceMock) { + return { + provide: LoaderService, + useFactory: () => mock ?? new LoaderServiceMock(), + }; +} diff --git a/src/testing/providers/route-provider.mock.ts b/src/testing/providers/route-provider.mock.ts index 53e8b3de0..739aea0b5 100644 --- a/src/testing/providers/route-provider.mock.ts +++ b/src/testing/providers/route-provider.mock.ts @@ -6,6 +6,7 @@ export class ActivatedRouteMockBuilder { private paramsObj: Record = {}; private queryParamsObj: Record = {}; private dataObj: Record = {}; + private firstChildBuilder: ActivatedRouteMockBuilder | null = null; private params$ = new BehaviorSubject>({}); private queryParams$ = new BehaviorSubject>({}); @@ -39,6 +40,12 @@ export class ActivatedRouteMockBuilder { return this; } + withFirstChild(configureFn: (builder: ActivatedRouteMockBuilder) => void): ActivatedRouteMockBuilder { + this.firstChildBuilder = new ActivatedRouteMockBuilder(); + configureFn(this.firstChildBuilder); + return this; + } + build(): Partial { const paramMap = { get: jest.fn((key: string) => this.paramsObj[key]), @@ -47,6 +54,8 @@ export class ActivatedRouteMockBuilder { keys: Object.keys(this.paramsObj), }; + const firstChild = this.firstChildBuilder ? this.firstChildBuilder.build() : null; + const route: Partial = { parent: { params: this.params$.asObservable(), @@ -59,7 +68,9 @@ export class ActivatedRouteMockBuilder { queryParams: this.queryParamsObj, data: this.dataObj, paramMap: paramMap, + firstChild: firstChild?.snapshot ?? null, } as any, + firstChild: firstChild as any, params: this.params$.asObservable(), queryParams: this.queryParams$.asObservable(), data: this.data$.asObservable(), @@ -87,3 +98,10 @@ export const ActivatedRouteMock = { return ActivatedRouteMockBuilder.create().withData(data); }, }; + +export function provideActivatedRouteMock(mock?: ReturnType) { + return { + provide: ActivatedRoute, + useFactory: () => mock ?? ActivatedRouteMockBuilder.create().build(), + }; +} diff --git a/src/testing/providers/router-provider.mock.ts b/src/testing/providers/router-provider.mock.ts index b13d86b59..be8268a33 100644 --- a/src/testing/providers/router-provider.mock.ts +++ b/src/testing/providers/router-provider.mock.ts @@ -60,3 +60,10 @@ export const RouterMock = { return RouterMockBuilder.create(); }, }; + +export function provideRouterMock(mock?: RouterMockType) { + return { + provide: Router, + useFactory: () => mock ?? RouterMockBuilder.create().build(), + }; +} From a13b596e791626201c2ea494724f0c194ebc1a5a Mon Sep 17 00:00:00 2001 From: nsemets Date: Tue, 17 Feb 2026 16:49:35 +0200 Subject: [PATCH 12/27] [ENG-10255] Part 2: Added unit tests for pages components in registries (#881) - Ticket: [ENG-10255] - Feature flag: n/a ## Summary of Changes 1. Added unit tests for components in registries-metadata-step. --- ...s-affiliated-institution.component.spec.ts | 79 +++---- ...stries-affiliated-institution.component.ts | 10 +- .../registries-contributors.component.spec.ts | 194 +++++++++++++----- .../registries-contributors.component.ts | 81 ++++---- .../registries-license.component.scss | 4 - .../registries-license.component.spec.ts | 139 +++++++++---- .../registries-license.component.ts | 95 ++++----- .../registries-metadata-step.component.html | 17 +- ...registries-metadata-step.component.spec.ts | 177 ++++++++++------ .../registries-metadata-step.component.ts | 79 ++++--- .../registries-subjects.component.spec.ts | 116 ++++++----- .../registries-subjects.component.ts | 36 ++-- .../registries-tags.component.spec.ts | 30 ++- .../registries-tags.component.ts | 15 +- .../select-components-dialog.component.ts | 18 +- src/app/features/registries/models/index.ts | 1 - .../store/handlers/projects.handlers.ts | 2 +- .../registries/store/registries.model.ts | 2 +- .../registries/store/registries.selectors.ts | 8 +- .../registries/store/registries.state.ts | 6 +- 20 files changed, 652 insertions(+), 457 deletions(-) delete mode 100644 src/app/features/registries/models/index.ts diff --git a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.spec.ts index 385ae946b..40e29ec95 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.spec.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.spec.ts @@ -1,47 +1,55 @@ +import { Store } from '@ngxs/store'; + import { MockComponent } from 'ng-mocks'; +import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; -import { InstitutionsSelectors } from '@osf/shared/stores/institutions'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; +import { + FetchResourceInstitutions, + FetchUserInstitutions, + InstitutionsSelectors, + UpdateResourceInstitutions, +} from '@osf/shared/stores/institutions'; import { RegistriesAffiliatedInstitutionComponent } from './registries-affiliated-institution.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { MOCK_INSTITUTION } from '@testing/mocks/institution.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesAffiliatedInstitutionComponent', () => { let component: RegistriesAffiliatedInstitutionComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; + let store: Store; + let resourceInstitutionsSignal: WritableSignal; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + beforeEach(() => { + resourceInstitutionsSignal = signal([]); - await TestBed.configureTestingModule({ - imports: [ - RegistriesAffiliatedInstitutionComponent, - OSFTestingModule, - MockComponent(AffiliatedInstitutionSelectComponent), - ], + TestBed.configureTestingModule({ + imports: [RegistriesAffiliatedInstitutionComponent, MockComponent(AffiliatedInstitutionSelectComponent)], providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, + provideOSFCore(), provideMockStore({ signals: [ { selector: InstitutionsSelectors.getUserInstitutions, value: [] }, { selector: InstitutionsSelectors.areUserInstitutionsLoading, value: false }, - { selector: InstitutionsSelectors.getResourceInstitutions, value: [] }, + { selector: InstitutionsSelectors.getResourceInstitutions, value: resourceInstitutionsSignal }, { selector: InstitutionsSelectors.areResourceInstitutionsLoading, value: false }, { selector: InstitutionsSelectors.areResourceInstitutionsSubmitting, value: false }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesAffiliatedInstitutionComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('draftId', 'draft-1'); fixture.detectChanges(); }); @@ -49,27 +57,26 @@ describe('RegistriesAffiliatedInstitutionComponent', () => { expect(component).toBeTruthy(); }); - it('should dispatch updateResourceInstitutions on selection', () => { - const actionsMock = { - updateResourceInstitutions: jest.fn(), - fetchUserInstitutions: jest.fn(), - fetchResourceInstitutions: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const selected = [{ id: 'i2' }] as any; - component.institutionsSelected(selected); - expect(actionsMock.updateResourceInstitutions).toHaveBeenCalledWith('draft-1', 8, selected); + it('should dispatch fetchUserInstitutions and fetchResourceInstitutions on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(new FetchUserInstitutions()); + expect(store.dispatch).toHaveBeenCalledWith( + new FetchResourceInstitutions('draft-1', ResourceType.DraftRegistration) + ); }); - it('should fetch user and resource institutions on init', () => { - const actionsMock = { - updateResourceInstitutions: jest.fn(), - fetchUserInstitutions: jest.fn(), - fetchResourceInstitutions: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - component.ngOnInit(); - expect(actionsMock.fetchUserInstitutions).toHaveBeenCalled(); - expect(actionsMock.fetchResourceInstitutions).toHaveBeenCalledWith('draft-1', 8); + it('should sync selectedInstitutions when resourceInstitutions emits', () => { + const institutions: Institution[] = [MOCK_INSTITUTION as Institution]; + resourceInstitutionsSignal.set(institutions); + fixture.detectChanges(); + expect(component.selectedInstitutions()).toEqual(institutions); + }); + + it('should dispatch updateResourceInstitutions on selection', () => { + (store.dispatch as jest.Mock).mockClear(); + const selected: Institution[] = [MOCK_INSTITUTION as Institution]; + component.institutionsSelected(selected); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateResourceInstitutions('draft-1', ResourceType.DraftRegistration, selected) + ); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts index 5fa7e1306..a16d71d2d 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts @@ -4,8 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; -import { ChangeDetectionStrategy, Component, effect, inject, OnInit, signal } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ChangeDetectionStrategy, Component, effect, input, OnInit, signal } from '@angular/core'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; @@ -25,8 +24,7 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistriesAffiliatedInstitutionComponent implements OnInit { - private readonly route = inject(ActivatedRoute); - private readonly draftId = this.route.snapshot.params['id']; + draftId = input.required(); selectedInstitutions = signal([]); @@ -53,10 +51,10 @@ export class RegistriesAffiliatedInstitutionComponent implements OnInit { ngOnInit() { this.actions.fetchUserInstitutions(); - this.actions.fetchResourceInstitutions(this.draftId, ResourceType.DraftRegistration); + this.actions.fetchResourceInstitutions(this.draftId(), ResourceType.DraftRegistration); } institutionsSelected(institutions: Institution[]) { - this.actions.updateResourceInstitutions(this.draftId, ResourceType.DraftRegistration, institutions); + this.actions.updateResourceInstitutions(this.draftId(), ResourceType.DraftRegistration, institutions); } } 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 1ee6a31b7..aecb80277 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 @@ -1,40 +1,59 @@ +import { Store } from '@ngxs/store'; + import { MockComponent, MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; +import { Subject } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; import { UserSelectors } from '@core/store/user'; +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 { ContributorsSelectors } from '@osf/shared/stores/contributors/contributors.selectors'; +import { + BulkAddContributors, + BulkUpdateContributors, + ContributorsSelectors, + DeleteContributor, + GetAllContributors, + LoadMoreContributors, + ResetContributorsState, +} from '@osf/shared/stores/contributors'; import { ContributorsTableComponent } from '@shared/components/contributors/contributors-table/contributors-table.component'; +import { ContributorModel } from '@shared/models/contributors/contributor.model'; +import { ContributorDialogAddModel } from '@shared/models/contributors/contributor-dialog-add.model'; import { RegistriesContributorsComponent } from './registries-contributors.component'; -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 { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { + MOCK_CONTRIBUTOR, + MOCK_CONTRIBUTOR_ADD, + MOCK_CONTRIBUTOR_WITHOUT_HISTORY, +} from '@testing/mocks/contributors.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMockBuilder, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { ToastServiceMockBuilder, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('RegistriesContributorsComponent', () => { let component: RegistriesContributorsComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockCustomDialogService: ReturnType; - let mockCustomConfirmationService: ReturnType; - let mockToast: ReturnType; + let store: Store; + let mockCustomDialogService: CustomDialogServiceMockType; + let mockCustomConfirmationService: CustomConfirmationServiceMockType; + let mockToast: ToastServiceMockType; - const initialContributors = [ - { id: '1', userId: 'u1', fullName: 'A', permission: 2 }, - { id: '2', userId: 'u2', fullName: 'B', permission: 1 }, - ] as any[]; + const initialContributors: ContributorModel[] = [MOCK_CONTRIBUTOR, MOCK_CONTRIBUTOR_WITHOUT_HISTORY]; beforeAll(() => { if (typeof (globalThis as any).structuredClone !== 'function') { @@ -46,88 +65,157 @@ describe('RegistriesContributorsComponent', () => { } }); - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + beforeEach(() => { mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); mockToast = ToastServiceMockBuilder.create().build(); - await TestBed.configureTestingModule({ - imports: [RegistriesContributorsComponent, OSFTestingModule, MockComponent(ContributorsTableComponent)], + TestBed.configureTestingModule({ + imports: [RegistriesContributorsComponent, MockComponent(ContributorsTableComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), + provideOSFCore(), MockProvider(CustomDialogService, mockCustomDialogService), MockProvider(CustomConfirmationService, mockCustomConfirmationService), MockProvider(ToastService, mockToast), provideMockStore({ signals: [ - { selector: UserSelectors.getCurrentUser, value: { id: 'u1' } }, + { selector: UserSelectors.getCurrentUser, value: { id: MOCK_CONTRIBUTOR.userId } }, { selector: ContributorsSelectors.getContributors, value: initialContributors }, { selector: ContributorsSelectors.isContributorsLoading, value: false }, + { selector: ContributorsSelectors.getContributorsTotalCount, value: 2 }, + { selector: ContributorsSelectors.isContributorsLoadingMore, value: false }, + { selector: ContributorsSelectors.getContributorsPageSize, value: 10 }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesContributorsComponent); component = fixture.componentInstance; fixture.componentRef.setInput('control', new FormControl([])); - const mockActions = { - getContributors: jest.fn().mockReturnValue(of({})), - updateContributor: jest.fn().mockReturnValue(of({})), - addContributor: jest.fn().mockReturnValue(of({})), - deleteContributor: jest.fn().mockReturnValue(of({})), - bulkUpdateContributors: jest.fn().mockReturnValue(of({})), - bulkAddContributors: jest.fn().mockReturnValue(of({})), - resetContributorsState: jest.fn().mockRejectedValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + fixture.componentRef.setInput('draftId', 'draft-1'); fixture.detectChanges(); }); - it('should request contributors on init', () => { - const actions = (component as any).actions; - expect(actions.getContributors).toHaveBeenCalledWith('draft-1', ResourceType.DraftRegistration); + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should dispatch getContributors on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(new GetAllContributors('draft-1', ResourceType.DraftRegistration)); }); it('should cancel changes and reset local contributors', () => { - (component as any).contributors.set([{ id: '3' }]); + component.contributors.set([{ ...MOCK_CONTRIBUTOR, id: 'changed' }]); component.cancel(); expect(component.contributors()).toEqual(JSON.parse(JSON.stringify(initialContributors))); }); it('should save changed contributors and show success toast', () => { - (component as any).contributors.set([{ ...initialContributors[0] }, { ...initialContributors[1], permission: 2 }]); + const changedContributor = { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, permission: MOCK_CONTRIBUTOR.permission }; + component.contributors.set([{ ...MOCK_CONTRIBUTOR }, changedContributor]); + (store.dispatch as jest.Mock).mockClear(); component.save(); - expect(mockToast.showSuccess).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + new BulkUpdateContributors('draft-1', ResourceType.DraftRegistration, [changedContributor]) + ); + expect(mockToast.showSuccess).toHaveBeenCalledWith( + 'project.contributors.toastMessages.multipleUpdateSuccessMessage' + ); + }); + + it('should bulk add registered contributors and show toast when add dialog closes', () => { + const dialogClose$ = new Subject(); + mockCustomDialogService.open.mockReturnValue({ onClose: dialogClose$, close: jest.fn() } as any); + (store.dispatch as jest.Mock).mockClear(); + + component.openAddContributorDialog(); + dialogClose$.next({ type: AddContributorType.Registered, data: [MOCK_CONTRIBUTOR_ADD] }); + + expect(store.dispatch).toHaveBeenCalledWith( + new BulkAddContributors('draft-1', ResourceType.DraftRegistration, [MOCK_CONTRIBUTOR_ADD]) + ); + expect(mockToast.showSuccess).toHaveBeenCalledWith('project.contributors.toastMessages.multipleAddSuccessMessage'); }); - it('should open add contributor dialog', () => { + it('should switch to unregistered dialog when add dialog closes with unregistered type', () => { + const dialogClose$ = new Subject(); + mockCustomDialogService.open.mockReturnValue({ onClose: dialogClose$, close: jest.fn() } as any); + const spy = jest.spyOn(component, 'openAddUnregisteredContributorDialog').mockImplementation(() => {}); + component.openAddContributorDialog(); - expect(mockCustomDialogService.open).toHaveBeenCalled(); + dialogClose$.next({ type: AddContributorType.Unregistered, data: [] }); + + expect(spy).toHaveBeenCalled(); }); - it('should open add unregistered contributor dialog', () => { + it('should bulk add unregistered contributor and show toast with name param', () => { + const dialogClose$ = new Subject(); + const unregisteredAdd = { ...MOCK_CONTRIBUTOR_ADD, fullName: 'Test User' }; + mockCustomDialogService.open.mockReturnValue({ onClose: dialogClose$, close: jest.fn() } as any); + (store.dispatch as jest.Mock).mockClear(); + component.openAddUnregisteredContributorDialog(); - expect(mockCustomDialogService.open).toHaveBeenCalled(); + dialogClose$.next({ type: AddContributorType.Unregistered, data: [unregisteredAdd] }); + + expect(store.dispatch).toHaveBeenCalledWith( + new BulkAddContributors('draft-1', ResourceType.DraftRegistration, [unregisteredAdd]) + ); + expect(mockToast.showSuccess).toHaveBeenCalledWith('project.contributors.toastMessages.addSuccessMessage', { + name: 'Test User', + }); + }); + + it('should switch to registered dialog when unregistered dialog closes with registered type', () => { + const dialogClose$ = new Subject(); + mockCustomDialogService.open.mockReturnValue({ onClose: dialogClose$, close: jest.fn() } as any); + const spy = jest.spyOn(component, 'openAddContributorDialog').mockImplementation(() => {}); + + component.openAddUnregisteredContributorDialog(); + dialogClose$.next({ type: AddContributorType.Registered, data: [] }); + + expect(spy).toHaveBeenCalled(); }); it('should remove contributor after confirmation and show success toast', () => { - const contributor = { id: '2', userId: 'u2', fullName: 'B' } as any; - component.removeContributor(contributor); + (store.dispatch as jest.Mock).mockClear(); + component.removeContributor(MOCK_CONTRIBUTOR_WITHOUT_HISTORY); expect(mockCustomConfirmationService.confirmDelete).toHaveBeenCalled(); - const call = (mockCustomConfirmationService.confirmDelete as any).mock.calls[0][0]; + const call = (mockCustomConfirmationService.confirmDelete as jest.Mock).mock.calls[0][0]; call.onConfirm(); - expect(mockToast.showSuccess).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + new DeleteContributor('draft-1', ResourceType.DraftRegistration, MOCK_CONTRIBUTOR_WITHOUT_HISTORY.userId) + ); + expect(mockToast.showSuccess).toHaveBeenCalledWith('project.contributors.removeDialog.successMessage', { + name: MOCK_CONTRIBUTOR_WITHOUT_HISTORY.fullName, + }); + }); + + it('should return true for hasChanges when contributors differ from initial', () => { + component.contributors.set([{ ...MOCK_CONTRIBUTOR, id: 'changed' }]); + expect(component.hasChanges).toBe(true); + }); + + it('should return false for hasChanges when contributors match initial', () => { + expect(component.hasChanges).toBe(false); + }); + + it('should dispatch resetContributorsState on destroy', () => { + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(new ResetContributorsState()); + }); + + it('should dispatch loadMoreContributors', () => { + (store.dispatch as jest.Mock).mockClear(); + component.loadMoreContributors(); + expect(store.dispatch).toHaveBeenCalledWith(new LoadMoreContributors('draft-1', ResourceType.DraftRegistration)); }); it('should mark control touched and dirty on focus out', () => { - const control = new FormControl([]); - const spy = jest.spyOn(control, 'updateValueAndValidity'); - fixture.componentRef.setInput('control', control); component.onFocusOut(); - expect(control.touched).toBe(true); - expect(control.dirty).toBe(true); - expect(spy).toHaveBeenCalled(); + expect(component.control().touched).toBe(true); + expect(component.control().dirty).toBe(true); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts index c69bc4e41..af89f0281 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts @@ -4,9 +4,8 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; -import { TableModule } from 'primeng/table'; -import { filter, map, of } from 'rxjs'; +import { EMPTY, filter, switchMap, tap } from 'rxjs'; import { ChangeDetectionStrategy, @@ -20,9 +19,8 @@ import { OnInit, signal, } from '@angular/core'; -import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; -import { FormControl, FormsModule } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl } from '@angular/forms'; import { AddContributorDialogComponent, @@ -51,21 +49,19 @@ import { TableParameters } from '@shared/models/table-parameters.model'; @Component({ selector: 'osf-registries-contributors', - imports: [FormsModule, TableModule, ContributorsTableComponent, TranslatePipe, Card, Button], + imports: [ContributorsTableComponent, TranslatePipe, Card, Button], templateUrl: './registries-contributors.component.html', styleUrl: './registries-contributors.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistriesContributorsComponent implements OnInit, OnDestroy { control = input.required(); + draftId = input.required(); - readonly destroyRef = inject(DestroyRef); - readonly customDialogService = inject(CustomDialogService); - readonly toastService = inject(ToastService); - readonly customConfirmationService = inject(CustomConfirmationService); - - private readonly route = inject(ActivatedRoute); - private readonly draftId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); + private readonly destroyRef = inject(DestroyRef); + private readonly customDialogService = inject(CustomDialogService); + private readonly toastService = inject(ToastService); + private readonly customConfirmationService = inject(CustomConfirmationService); initialContributors = select(ContributorsSelectors.getContributors); contributors = signal([]); @@ -112,11 +108,10 @@ export class RegistriesContributorsComponent implements OnInit, OnDestroy { } onFocusOut() { - if (this.control()) { - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); - } + const control = this.control(); + control.markAsTouched(); + control.markAsDirty(); + control.updateValueAndValidity(); } cancel() { @@ -142,20 +137,21 @@ export class RegistriesContributorsComponent implements OnInit, OnDestroy { }) .onClose.pipe( filter((res: ContributorDialogAddModel) => !!res), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe((res: ContributorDialogAddModel) => { - if (res.type === AddContributorType.Unregistered) { - this.openAddUnregisteredContributorDialog(); - } else { - this.actions + switchMap((res: ContributorDialogAddModel) => { + if (res.type === AddContributorType.Unregistered) { + this.openAddUnregisteredContributorDialog(); + return EMPTY; + } + + return this.actions .bulkAddContributors(this.draftId(), ResourceType.DraftRegistration, res.data) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => - this.toastService.showSuccess('project.contributors.toastMessages.multipleAddSuccessMessage') + .pipe( + tap(() => this.toastService.showSuccess('project.contributors.toastMessages.multipleAddSuccessMessage')) ); - } - }); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); } openAddUnregisteredContributorDialog() { @@ -166,19 +162,22 @@ export class RegistriesContributorsComponent implements OnInit, OnDestroy { }) .onClose.pipe( filter((res: ContributorDialogAddModel) => !!res), + switchMap((res) => { + if (res.type === AddContributorType.Registered) { + this.openAddContributorDialog(); + return EMPTY; + } + + const params = { name: res.data[0].fullName }; + return this.actions + .bulkAddContributors(this.draftId(), ResourceType.DraftRegistration, res.data) + .pipe( + tap(() => this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params)) + ); + }), takeUntilDestroyed(this.destroyRef) ) - .subscribe((res: ContributorDialogAddModel) => { - if (res.type === AddContributorType.Registered) { - this.openAddContributorDialog(); - } else { - const params = { name: res.data[0].fullName }; - - this.actions.bulkAddContributors(this.draftId(), ResourceType.DraftRegistration, res.data).subscribe({ - next: () => this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params), - }); - } - }); + .subscribe(); } removeContributor(contributor: ContributorModel) { diff --git a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.scss b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.scss index 7f863186d..e69de29bb 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.scss +++ b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.scss @@ -1,4 +0,0 @@ -.highlight-block { - padding: 0.5rem; - background-color: var(--bg-blue-2); -} diff --git a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.spec.ts index bd5a86de0..18a8e69c4 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.spec.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.spec.ts @@ -1,48 +1,57 @@ +import { Store } from '@ngxs/store'; + import { MockComponent } from 'ng-mocks'; +import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl, FormGroup } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { FetchLicenses, RegistriesSelectors, SaveLicense } from '@osf/features/registries/store'; import { LicenseComponent } from '@osf/shared/components/license/license.component'; +import { LicenseModel } from '@shared/models/license/license.model'; import { RegistriesLicenseComponent } from './registries-license.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesLicenseComponent', () => { let component: RegistriesLicenseComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; + let store: Store; + let licensesSignal: WritableSignal; + let selectedLicenseSignal: WritableSignal<{ id: string; options?: Record } | null>; + let draftRegistrationSignal: WritableSignal | null>; + + const mockLicense: LicenseModel = { id: 'lic-1', name: 'MIT', requiredFields: [], url: '', text: '' }; + const mockDefaultLicense: LicenseModel = { id: 'default-1', name: 'Default', requiredFields: [], url: '', text: '' }; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + beforeEach(() => { + licensesSignal = signal([]); + selectedLicenseSignal = signal<{ id: string; options?: Record } | null>(null); + draftRegistrationSignal = signal | null>({ + providerId: 'osf', + }); - await TestBed.configureTestingModule({ - imports: [RegistriesLicenseComponent, OSFTestingModule, MockComponent(LicenseComponent)], + TestBed.configureTestingModule({ + imports: [RegistriesLicenseComponent, MockComponent(LicenseComponent)], providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, + provideOSFCore(), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getLicenses, value: [] }, - { selector: RegistriesSelectors.getSelectedLicense, value: null }, - { selector: RegistriesSelectors.getDraftRegistration, value: { providerId: 'osf' } }, + { selector: RegistriesSelectors.getLicenses, value: licensesSignal }, + { selector: RegistriesSelectors.getSelectedLicense, value: selectedLicenseSignal }, + { selector: RegistriesSelectors.getDraftRegistration, value: draftRegistrationSignal }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesLicenseComponent); component = fixture.componentInstance; fixture.componentRef.setInput('control', new FormGroup({ id: new FormControl('') })); - const mockActions = { - fetchLicenses: jest.fn().mockReturnValue({}), - saveLicense: jest.fn().mockReturnValue({}), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + fixture.componentRef.setInput('draftId', 'draft-1'); fixture.detectChanges(); }); @@ -50,36 +59,90 @@ describe('RegistriesLicenseComponent', () => { expect(component).toBeTruthy(); }); - it('should fetch licenses on init when draft present', () => { - expect((component as any).actions.fetchLicenses).toHaveBeenCalledWith('osf'); + it('should dispatch fetchLicenses on init when draft present', () => { + expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses('osf')); + }); + + it('should fetch licenses only once even if draft re-emits', () => { + (store.dispatch as jest.Mock).mockClear(); + draftRegistrationSignal.set({ providerId: 'other' }); + fixture.detectChanges(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchLicenses)); + }); + + it('should sync selected license to control when license exists in list', () => { + licensesSignal.set([mockLicense]); + selectedLicenseSignal.set({ id: 'lic-1' }); + fixture.detectChanges(); + expect(component.control().get('id')?.value).toBe('lic-1'); + }); + + it('should apply default license and save when no selected license', () => { + (store.dispatch as jest.Mock).mockClear(); + draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'default-1' }); + licensesSignal.set([mockDefaultLicense]); + fixture.detectChanges(); + expect(component.control().get('id')?.value).toBe('default-1'); + expect(store.dispatch).toHaveBeenCalledWith(new SaveLicense('draft-1', 'default-1')); + }); + + it('should apply default license but not save when it has required fields', () => { + (store.dispatch as jest.Mock).mockClear(); + const licenseWithFields: LicenseModel = { + id: 'default-2', + name: 'CC-BY', + requiredFields: ['year'], + url: '', + text: '', + }; + draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'default-2' }); + licensesSignal.set([licenseWithFields]); + fixture.detectChanges(); + expect(component.control().get('id')?.value).toBe('default-2'); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SaveLicense)); + }); + + it('should prefer selected license over default license', () => { + licensesSignal.set([mockDefaultLicense, mockLicense]); + draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'default-1' }); + selectedLicenseSignal.set({ id: 'lic-1' }); + fixture.detectChanges(); + expect(component.control().get('id')?.value).toBe('lic-1'); }); it('should set control id and save license when selecting simple license', () => { - const saveSpy = jest.spyOn((component as any).actions, 'saveLicense'); - component.selectLicense({ id: 'lic-1', requiredFields: [] } as any); - expect((component.control() as FormGroup).get('id')?.value).toBe('lic-1'); - expect(saveSpy).toHaveBeenCalledWith('draft-1', 'lic-1'); + (store.dispatch as jest.Mock).mockClear(); + component.selectLicense(mockLicense); + expect(component.control().get('id')?.value).toBe('lic-1'); + expect(store.dispatch).toHaveBeenCalledWith(new SaveLicense('draft-1', 'lic-1')); }); it('should not save when license has required fields', () => { - const saveSpy = jest.spyOn((component as any).actions, 'saveLicense'); - component.selectLicense({ id: 'lic-2', requiredFields: ['year'] } as any); - expect(saveSpy).not.toHaveBeenCalled(); + (store.dispatch as jest.Mock).mockClear(); + component.selectLicense({ id: 'lic-2', name: 'CC-BY', requiredFields: ['year'], url: '', text: '' }); + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should create license with options', () => { - const saveSpy = jest.spyOn((component as any).actions, 'saveLicense'); - component.createLicense({ id: 'lic-3', licenseOptions: { year: '2024', copyrightHolders: 'Me' } as any }); - expect(saveSpy).toHaveBeenCalledWith('draft-1', 'lic-3', { year: '2024', copyrightHolders: 'Me' }); + it('should dispatch saveLicense with options on createLicense', () => { + (store.dispatch as jest.Mock).mockClear(); + component.createLicense({ id: 'lic-3', licenseOptions: { year: '2024', copyrightHolders: 'Me' } }); + expect(store.dispatch).toHaveBeenCalledWith( + new SaveLicense('draft-1', 'lic-3', { year: '2024', copyrightHolders: 'Me' }) + ); + }); + + it('should not apply default license when defaultLicenseId is not in the list', () => { + (store.dispatch as jest.Mock).mockClear(); + draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'non-existent' }); + licensesSignal.set([mockLicense]); + fixture.detectChanges(); + expect(component.control().get('id')?.value).toBe(''); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SaveLicense)); }); it('should mark control on focus out', () => { - const control = new FormGroup({ id: new FormControl('') }); - fixture.componentRef.setInput('control', control); - const spy = jest.spyOn(control, 'updateValueAndValidity'); component.onFocusOut(); - expect(control.touched).toBe(true); - expect(control.dirty).toBe(true); - expect(spy).toHaveBeenCalled(); + expect(component.control().touched).toBe(true); + expect(component.control().dirty).toBe(true); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.ts index a33da20e0..7225338ca 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.ts @@ -5,113 +5,100 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; -import { ChangeDetectionStrategy, Component, effect, inject, input } from '@angular/core'; -import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; +import { ChangeDetectionStrategy, Component, effect, inject, input, signal } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { FetchLicenses, RegistriesSelectors, SaveLicense } from '@osf/features/registries/store'; import { LicenseComponent } from '@osf/shared/components/license/license.component'; -import { InputLimits } from '@osf/shared/constants/input-limits.const'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; import { LicenseModel, LicenseOptions } from '@shared/models/license/license.model'; @Component({ selector: 'osf-registries-license', - imports: [FormsModule, ReactiveFormsModule, LicenseComponent, Card, TranslatePipe, Message], + imports: [ReactiveFormsModule, LicenseComponent, Card, TranslatePipe, Message], templateUrl: './registries-license.component.html', styleUrl: './registries-license.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistriesLicenseComponent { control = input.required(); + draftId = input.required(); - private readonly route = inject(ActivatedRoute); private readonly environment = inject(ENVIRONMENT); - private readonly draftId = this.route.snapshot.params['id']; actions = createDispatchMap({ fetchLicenses: FetchLicenses, saveLicense: SaveLicense }); - licenses = select(RegistriesSelectors.getLicenses); - inputLimits = InputLimits; + licenses = select(RegistriesSelectors.getLicenses); selectedLicense = select(RegistriesSelectors.getSelectedLicense); draftRegistration = select(RegistriesSelectors.getDraftRegistration); - currentYear = new Date(); - readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - private isLoaded = false; + private readonly licensesLoaded = signal(false); constructor() { effect(() => { - if (this.draftRegistration() && !this.isLoaded) { + if (this.draftRegistration() && !this.licensesLoaded()) { this.actions.fetchLicenses(this.draftRegistration()?.providerId ?? this.environment.defaultProvider); - this.isLoaded = true; + this.licensesLoaded.set(true); } }); effect(() => { - const selectedLicense = this.selectedLicense(); - if (!selectedLicense) { - return; - } - - this.control().patchValue({ - id: selectedLicense.id, - }); - }); - - effect(() => { + const control = this.control(); const licenses = this.licenses(); const selectedLicense = this.selectedLicense(); const defaultLicenseId = this.draftRegistration()?.defaultLicenseId; - if (!licenses.length) { + if (selectedLicense && licenses.some((l) => l.id === selectedLicense.id)) { + control.patchValue({ id: selectedLicense.id }); return; } - if ( - defaultLicenseId && - (!selectedLicense?.id || !licenses.find((license) => license.id === selectedLicense?.id)) - ) { - const defaultLicense = licenses.find((license) => license.id === defaultLicenseId); - if (defaultLicense) { - this.control().patchValue({ - id: defaultLicense.id, - }); - this.control().markAsTouched(); - this.control().updateValueAndValidity(); - - if (!defaultLicense.requiredFields.length) { - this.actions.saveLicense(this.draftId, defaultLicense.id); - } - } - } + this.applyDefaultLicense(control, licenses, defaultLicenseId); }); } createLicense(licenseDetails: { id: string; licenseOptions: LicenseOptions }) { - this.actions.saveLicense(this.draftId, licenseDetails.id, licenseDetails.licenseOptions); + this.actions.saveLicense(this.draftId(), licenseDetails.id, licenseDetails.licenseOptions); } selectLicense(license: LicenseModel) { if (license.requiredFields.length) { return; } - this.control().patchValue({ - id: license.id, - }); - this.control().markAsTouched(); - this.control().updateValueAndValidity(); - this.actions.saveLicense(this.draftId, license.id); + + const control = this.control(); + control.patchValue({ id: license.id }); + control.markAsTouched(); + control.updateValueAndValidity(); + this.actions.saveLicense(this.draftId(), license.id); } onFocusOut() { - if (this.control()) { - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); + const control = this.control(); + control.markAsTouched(); + control.markAsDirty(); + control.updateValueAndValidity(); + } + + private applyDefaultLicense(control: FormGroup, licenses: LicenseModel[], defaultLicenseId?: string) { + if (!licenses.length || !defaultLicenseId) { + return; + } + + const defaultLicense = licenses.find((license) => license.id === defaultLicenseId); + if (!defaultLicense) { + return; + } + + control.patchValue({ id: defaultLicense.id }); + control.markAsTouched(); + control.updateValueAndValidity(); + + if (!defaultLicense.requiredFields.length) { + this.actions.saveLicense(this.draftId(), defaultLicense.id); } } } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.html b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.html index 98f184e8f..04af77dbc 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.html +++ b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.html @@ -39,7 +39,6 @@

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

rows="5" cols="30" pTextarea - [ariaLabel]="'common.labels.description' | translate" > @if ( metadataForm.controls['description'].errors?.['required'] && @@ -54,11 +53,17 @@

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

- - - - - + + + + +
{ +describe('RegistriesMetadataStepComponent', () => { + ngMocks.faster(); + let component: RegistriesMetadataStepComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; + let store: Store; + let mockRouter: RouterMockType; + let stepsStateSignal: WritableSignal<{ invalid: boolean }[]>; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + const mockDraft = { ...MOCK_DRAFT_REGISTRATION, title: 'Test Title', description: 'Test Description' }; + + beforeEach(() => { + const mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); mockRouter = RouterMockBuilder.create().withUrl('/registries/osf/draft/draft-1/metadata').build(); + stepsStateSignal = signal<{ invalid: boolean }[]>([{ invalid: true }]); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ RegistriesMetadataStepComponent, - OSFTestingModule, + MockModule(TextareaModule), ...MockComponents( TextInputComponent, RegistriesContributorsComponent, @@ -47,20 +60,23 @@ describe.skip('RegistriesMetadataStepComponent', () => { ), ], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(Router, mockRouter), - MockProvider(CustomConfirmationService, { confirmDelete: jest.fn() }), + provideOSFCore(), + provideActivatedRouteMock(mockActivatedRoute), + provideRouterMock(mockRouter), + MockCustomConfirmationServiceProvider, provideMockStore({ signals: [ - { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false } } }, + { selector: RegistriesSelectors.getDraftRegistration, value: mockDraft }, + { selector: RegistriesSelectors.getStepsState, value: stepsStateSignal }, + { selector: RegistriesSelectors.hasDraftAdminAccess, value: true }, { selector: ContributorsSelectors.getContributors, value: [] }, { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, - { selector: InstitutionsSelectors.getResourceInstitutions, value: [] }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesMetadataStepComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -70,66 +86,97 @@ describe.skip('RegistriesMetadataStepComponent', () => { expect(component).toBeTruthy(); }); - it('should initialize form with draft data', () => { - expect(component.metadataForm.value.title).toBe(' My Title '); - expect(component.metadataForm.value.description).toBe(' Description '); - expect(component.metadataForm.value.license).toEqual({ id: 'mit' }); + it('should initialize metadataForm with required controls', () => { + expect(component.metadataForm.get('title')).toBeTruthy(); + expect(component.metadataForm.get('description')).toBeTruthy(); + expect(component.metadataForm.get('contributors')).toBeTruthy(); + expect(component.metadataForm.get('subjects')).toBeTruthy(); + expect(component.metadataForm.get('tags')).toBeTruthy(); + expect(component.metadataForm.get('license.id')).toBeTruthy(); }); - it('should compute hasAdminAccess', () => { - expect(component.hasAdminAccess()).toBe(true); + it('should have form invalid when title is empty', () => { + component.metadataForm.patchValue({ title: '', description: 'Valid' }); + expect(component.metadataForm.get('title')?.valid).toBe(false); }); - it('should submit metadata, trim values and navigate to first step', () => { - const actionsMock = { - updateDraft: jest.fn().mockReturnValue({ pipe: () => ({ subscribe: jest.fn() }) }), - deleteDraft: jest.fn(), - clearState: jest.fn(), - updateStepState: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + it('should submit metadata and navigate to step 1', () => { + component.metadataForm.patchValue({ title: 'New Title', description: 'New Desc' }); + (store.dispatch as jest.Mock).mockClear(); component.submitMetadata(); - expect(actionsMock.updateDraft).toHaveBeenCalledWith('draft-1', { - title: 'My Title', - description: 'Description', - }); - expect(navSpy).toHaveBeenCalledWith(['../1'], { - relativeTo: TestBed.inject(ActivatedRoute), - onSameUrlNavigation: 'reload', - }); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateDraft('draft-1', { title: 'New Title', description: 'New Desc' }) + ); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../1'], + expect.objectContaining({ onSameUrlNavigation: 'reload' }) + ); }); - it('should delete draft on confirm and navigate to new registration', () => { - const confirmService = TestBed.inject(CustomConfirmationService) as jest.Mocked as any; - const actionsMock = { - deleteDraft: jest.fn().mockReturnValue({ subscribe: ({ next }: any) => next() }), - clearState: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigateByUrl'); + it('should trim title and description on submit', () => { + component.metadataForm.patchValue({ title: ' Padded Title ', description: ' Padded Desc ' }); + (store.dispatch as jest.Mock).mockClear(); - (confirmService.confirmDelete as jest.Mock).mockImplementation(({ onConfirm }) => onConfirm()); + component.submitMetadata(); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateDraft('draft-1', { title: 'Padded Title', description: 'Padded Desc' }) + ); + }); + + it('should call confirmDelete when deleteDraft is called', () => { component.deleteDraft(); + expect(CustomConfirmationServiceMock.confirmDelete).toHaveBeenCalledWith( + expect.objectContaining({ + headerKey: 'registries.deleteDraft', + messageKey: 'registries.confirmDeleteDraft', + }) + ); + }); - expect(actionsMock.clearState).toHaveBeenCalled(); - expect(navSpy).toHaveBeenCalledWith('/registries/osf/new'); + it('should set isDraftDeleted and navigate on deleteDraft confirm', () => { + CustomConfirmationServiceMock.confirmDelete.mockImplementation(({ onConfirm }: any) => onConfirm()); + (store.dispatch as jest.Mock).mockClear(); + + component.deleteDraft(); + + expect(component.isDraftDeleted).toBe(true); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteDraft('draft-1')); + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/registries/osf/new'); + }); + + it('should skip updates on destroy when isDraftDeleted is true', () => { + (store.dispatch as jest.Mock).mockClear(); + component.isDraftDeleted = true; + component.ngOnDestroy(); + + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should update step state and draft on destroy if changed', () => { - const actionsMock = { - updateStepState: jest.fn(), - updateDraft: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); + it('should update step state on destroy when fields are unchanged', () => { + component.metadataForm.patchValue({ title: 'Test Title', description: 'Test Description' }); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdateStepState('0', expect.any(Boolean), true)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdateDraft)); + }); - component.metadataForm.patchValue({ title: 'Changed', description: 'Changed desc' }); - fixture.destroy(); + it('should dispatch updateDraft on destroy when fields have changed', () => { + component.metadataForm.patchValue({ title: 'Changed Title', description: 'Test Description' }); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdateStepState('0', expect.any(Boolean), true)); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateDraft('draft-1', expect.objectContaining({ title: 'Changed Title' })) + ); + }); - expect(actionsMock.updateStepState).toHaveBeenCalledWith('0', true, true); - expect(actionsMock.updateDraft).toHaveBeenCalledWith('draft-1', { title: 'Changed', description: 'Changed desc' }); + it('should mark form as touched when step state is invalid on init', () => { + expect(component.metadataForm.touched).toBe(true); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.ts index bfcc1e5f0..589ec9174 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.ts @@ -7,9 +7,10 @@ import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; import { TextareaModule } from 'primeng/textarea'; -import { tap } from 'rxjs'; +import { filter, take, tap } from 'rxjs'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, OnDestroy } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnDestroy } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; @@ -21,7 +22,6 @@ import { findChangedFields } from '@osf/shared/helpers/find-changed-fields'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; import { SubjectsSelectors } from '@osf/shared/stores/subjects'; -import { UserPermissions } from '@shared/enums/user-permissions.enum'; import { ContributorModel } from '@shared/models/contributors/contributor.model'; import { DraftRegistrationModel } from '@shared/models/registration/draft-registration.model'; import { SubjectModel } from '@shared/models/subject/subject.model'; @@ -58,21 +58,18 @@ export class RegistriesMetadataStepComponent implements OnDestroy { private readonly fb = inject(FormBuilder); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); private readonly customConfirmationService = inject(CustomConfirmationService); readonly titleLimit = InputLimits.title.maxLength; - private readonly draftId = this.route.snapshot.params['id']; - readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + readonly draftId = this.route.snapshot.params['id']; + + draftRegistration = select(RegistriesSelectors.getDraftRegistration); selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); initialContributors = select(ContributorsSelectors.getContributors); stepsState = select(RegistriesSelectors.getStepsState); - - hasAdminAccess = computed(() => { - const registry = this.draftRegistration(); - if (!registry) return false; - return registry.currentUserPermissions.includes(UserPermissions.Admin); - }); + hasAdminAccess = select(RegistriesSelectors.hasDraftAdminAccess); actions = createDispatchMap({ deleteDraft: DeleteDraft, @@ -89,35 +86,29 @@ export class RegistriesMetadataStepComponent implements OnDestroy { contributors: [[] as ContributorModel[], Validators.required], subjects: [[] as SubjectModel[], Validators.required], tags: [[]], - license: this.fb.group({ - id: ['', Validators.required], - }), + license: this.fb.group({ id: ['', Validators.required] }), }); isDraftDeleted = false; - isFormUpdated = false; constructor() { - effect(() => { - const draft = this.draftRegistration(); - // TODO: This shouldn't be an effect() - if (draft && !this.isFormUpdated) { - this.updateFormValue(draft); - this.isFormUpdated = true; - } - }); + toObservable(this.draftRegistration) + .pipe(filter(Boolean), take(1), takeUntilDestroyed(this.destroyRef)) + .subscribe((draft) => this.updateFormValue(draft)); } - private updateFormValue(data: DraftRegistrationModel): void { - this.metadataForm.patchValue({ - title: data.title, - description: data.description, - license: data.license, - contributors: this.initialContributors(), - subjects: this.selectedSubjects(), - }); - if (this.stepsState()?.[0]?.invalid) { - this.metadataForm.markAllAsTouched(); + ngOnDestroy(): void { + if (!this.isDraftDeleted) { + this.actions.updateStepState('0', this.metadataForm.invalid, true); + const changedFields = findChangedFields( + { title: this.metadataForm.value.title!, description: this.metadataForm.value.description! }, + { title: this.draftRegistration()?.title, description: this.draftRegistration()?.description } + ); + + if (Object.keys(changedFields).length > 0) { + this.actions.updateDraft(this.draftId, changedFields); + this.metadataForm.markAllAsTouched(); + } } } @@ -156,17 +147,17 @@ export class RegistriesMetadataStepComponent implements OnDestroy { }); } - ngOnDestroy(): void { - if (!this.isDraftDeleted) { - this.actions.updateStepState('0', this.metadataForm.invalid, true); - const changedFields = findChangedFields( - { title: this.metadataForm.value.title!, description: this.metadataForm.value.description! }, - { title: this.draftRegistration()?.title, description: this.draftRegistration()?.description } - ); - if (Object.keys(changedFields).length > 0) { - this.actions.updateDraft(this.draftId, changedFields); - this.metadataForm.markAllAsTouched(); - } + private updateFormValue(data: DraftRegistrationModel): void { + this.metadataForm.patchValue({ + title: data.title, + description: data.description, + license: data.license, + contributors: this.initialContributors(), + subjects: this.selectedSubjects(), + }); + + if (this.stepsState()?.[0]?.invalid) { + this.metadataForm.markAllAsTouched(); } } } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.spec.ts index 9c8ecbff4..f86e440b3 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.spec.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.spec.ts @@ -1,57 +1,58 @@ -import { MockComponent, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { of } from 'rxjs'; +import { MockComponent } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormControl } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; +import { FormControl, Validators } from '@angular/forms'; import { RegistriesSelectors } from '@osf/features/registries/store'; import { SubjectsComponent } from '@osf/shared/components/subjects/subjects.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { SubjectsSelectors } from '@osf/shared/stores/subjects'; +import { + FetchChildrenSubjects, + FetchSelectedSubjects, + FetchSubjects, + SubjectsSelectors, + UpdateResourceSubjects, +} from '@osf/shared/stores/subjects'; +import { SubjectModel } from '@shared/models/subject/subject.model'; import { RegistriesSubjectsComponent } from './registries-subjects.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { MOCK_DRAFT_REGISTRATION } from '@testing/mocks/draft-registration.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesSubjectsComponent', () => { let component: RegistriesSubjectsComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; + let store: Store; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); - await TestBed.configureTestingModule({ - imports: [RegistriesSubjectsComponent, OSFTestingModule, MockComponent(SubjectsComponent)], + const mockSubjects: SubjectModel[] = [ + { id: 'sub-1', name: 'Subject 1' }, + { id: 'sub-2', name: 'Subject 2' }, + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RegistriesSubjectsComponent, MockComponent(SubjectsComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), + provideOSFCore(), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getDraftRegistration, value: { providerId: 'prov-1' } }, { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, - { selector: SubjectsSelectors.getSubjects, value: [] }, - { selector: SubjectsSelectors.getSearchedSubjects, value: [] }, - { selector: SubjectsSelectors.getSubjectsLoading, value: false }, - { selector: SubjectsSelectors.getSearchedSubjectsLoading, value: false }, { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false }, + { selector: RegistriesSelectors.getDraftRegistration, value: MOCK_DRAFT_REGISTRATION }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesSubjectsComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('control', new FormControl([])); - const mockActions = { - fetchSubjects: jest.fn().mockReturnValue(of({})), - fetchSelectedSubjects: jest.fn().mockReturnValue(of({})), - fetchChildrenSubjects: jest.fn().mockReturnValue(of({})), - updateResourceSubjects: jest.fn().mockReturnValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + fixture.componentRef.setInput('control', new FormControl(null, Validators.required)); + fixture.componentRef.setInput('draftId', 'draft-1'); fixture.detectChanges(); }); @@ -59,33 +60,54 @@ describe('RegistriesSubjectsComponent', () => { expect(component).toBeTruthy(); }); - it('should fetch subjects and selected subjects on init', () => { - const actions = (component as any).actions; - expect(actions.fetchSubjects).toHaveBeenCalledWith(ResourceType.Registration, 'prov-1'); - expect(actions.fetchSelectedSubjects).toHaveBeenCalledWith('draft-1', ResourceType.DraftRegistration); + it('should dispatch fetchSubjects and fetchSelectedSubjects on init', () => { + expect(store.dispatch).toHaveBeenCalledWith( + new FetchSubjects(ResourceType.Registration, MOCK_DRAFT_REGISTRATION.providerId) + ); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSelectedSubjects('draft-1', ResourceType.DraftRegistration)); }); - it('should fetch children on demand', () => { - const actions = (component as any).actions; + it('should dispatch fetchChildrenSubjects on getSubjectChildren', () => { + (store.dispatch as jest.Mock).mockClear(); component.getSubjectChildren('parent-1'); - expect(actions.fetchChildrenSubjects).toHaveBeenCalledWith('parent-1'); + expect(store.dispatch).toHaveBeenCalledWith(new FetchChildrenSubjects('parent-1')); }); - it('should search subjects', () => { - const actions = (component as any).actions; - component.searchSubjects('term'); - expect(actions.fetchSubjects).toHaveBeenCalledWith(ResourceType.Registration, 'prov-1', 'term'); + it('should dispatch fetchSubjects with search term on searchSubjects', () => { + (store.dispatch as jest.Mock).mockClear(); + component.searchSubjects('biology'); + expect(store.dispatch).toHaveBeenCalledWith( + new FetchSubjects(ResourceType.Registration, MOCK_DRAFT_REGISTRATION.providerId, 'biology') + ); }); - it('should update selected subjects and control state', () => { - const actions = (component as any).actions; - const nextSubjects = [{ id: 's1' } as any]; - component.updateSelectedSubjects(nextSubjects); - expect(actions.updateResourceSubjects).toHaveBeenCalledWith( - 'draft-1', - ResourceType.DraftRegistration, - nextSubjects + it('should dispatch updateResourceSubjects and update control on updateSelectedSubjects', () => { + (store.dispatch as jest.Mock).mockClear(); + component.updateSelectedSubjects(mockSubjects); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateResourceSubjects('draft-1', ResourceType.DraftRegistration, mockSubjects) ); - expect(component.control().value).toEqual(nextSubjects); + expect(component.control().value).toEqual(mockSubjects); + expect(component.control().touched).toBe(true); + expect(component.control().dirty).toBe(true); + }); + + it('should mark control as touched and dirty on focusout', () => { + component.onFocusOut(); + expect(component.control().touched).toBe(true); + expect(component.control().dirty).toBe(true); + }); + + it('should have invalid control when value is null', () => { + component.control().markAsTouched(); + component.control().updateValueAndValidity(); + expect(component.control().valid).toBe(false); + expect(component.control().errors?.['required']).toBeTruthy(); + }); + + it('should have valid control when subjects are set', () => { + component.updateControlState(mockSubjects); + expect(component.control().valid).toBe(true); + expect(component.control().errors).toBeNull(); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.ts index 01915ab96..9e0f38db6 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.ts @@ -5,9 +5,8 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; -import { ChangeDetectionStrategy, Component, effect, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, input, signal } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; import { RegistriesSelectors } from '@osf/features/registries/store'; import { SubjectsComponent } from '@osf/shared/components/subjects/subjects.component'; @@ -31,8 +30,7 @@ import { SubjectModel } from '@shared/models/subject/subject.model'; }) export class RegistriesSubjectsComponent { control = input.required(); - private readonly route = inject(ActivatedRoute); - private readonly draftId = this.route.snapshot.params['id']; + draftId = input.required(); selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); @@ -47,14 +45,14 @@ export class RegistriesSubjectsComponent { readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - private isLoaded = false; + private readonly isLoaded = signal(false); constructor() { effect(() => { - if (this.draftRegistration() && !this.isLoaded) { + if (this.draftRegistration() && !this.isLoaded()) { this.actions.fetchSubjects(ResourceType.Registration, this.draftRegistration()?.providerId); - this.actions.fetchSelectedSubjects(this.draftId, ResourceType.DraftRegistration); - this.isLoaded = true; + this.actions.fetchSelectedSubjects(this.draftId(), ResourceType.DraftRegistration); + this.isLoaded.set(true); } }); } @@ -69,23 +67,21 @@ export class RegistriesSubjectsComponent { updateSelectedSubjects(subjects: SubjectModel[]) { this.updateControlState(subjects); - this.actions.updateResourceSubjects(this.draftId, ResourceType.DraftRegistration, subjects); + this.actions.updateResourceSubjects(this.draftId(), ResourceType.DraftRegistration, subjects); } onFocusOut() { - if (this.control()) { - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); - } + const control = this.control(); + control.markAsTouched(); + control.markAsDirty(); + control.updateValueAndValidity(); } updateControlState(value: SubjectModel[]) { - if (this.control()) { - this.control().setValue(value); - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); - } + const control = this.control(); + control.setValue(value); + control.markAsTouched(); + control.markAsDirty(); + control.updateValueAndValidity(); } } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.spec.ts index 4072f1ed6..914396af1 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.spec.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.spec.ts @@ -1,36 +1,34 @@ -import { of } from 'rxjs'; +import { Store } from '@ngxs/store'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { RegistriesSelectors, UpdateDraft } from '@osf/features/registries/store'; import { RegistriesTagsComponent } from './registries-tags.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesTagsComponent', () => { let component: RegistriesTagsComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; + let store: Store; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'someId' }).build(); - - await TestBed.configureTestingModule({ - imports: [RegistriesTagsComponent, OSFTestingModule], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RegistriesTagsComponent], providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, + provideOSFCore(), provideMockStore({ signals: [{ selector: RegistriesSelectors.getSelectedTags, value: [] }], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesTagsComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('draftId', 'someId'); fixture.detectChanges(); }); @@ -44,11 +42,7 @@ describe('RegistriesTagsComponent', () => { }); it('should update tags on change', () => { - const mockActions = { - updateDraft: jest.fn().mockReturnValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); component.onTagsChanged(['a', 'b']); - expect(mockActions.updateDraft).toHaveBeenCalledWith('someId', { tags: ['a', 'b'] }); + expect(store.dispatch).toHaveBeenCalledWith(new UpdateDraft('someId', { tags: ['a', 'b'] })); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.ts index 5c8c32cd1..dcba22a36 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.ts @@ -4,8 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { RegistriesSelectors, UpdateDraft } from '@osf/features/registries/store'; import { TagsInputComponent } from '@osf/shared/components/tags-input/tags-input.component'; @@ -18,15 +17,13 @@ import { TagsInputComponent } from '@osf/shared/components/tags-input/tags-input changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistriesTagsComponent { - private readonly route = inject(ActivatedRoute); - private readonly draftId = this.route.snapshot.params['id']; - selectedTags = select(RegistriesSelectors.getSelectedTags); + draftId = input.required(); + + actions = createDispatchMap({ updateDraft: UpdateDraft }); - actions = createDispatchMap({ - updateDraft: UpdateDraft, - }); + selectedTags = select(RegistriesSelectors.getSelectedTags); onTagsChanged(tags: string[]): void { - this.actions.updateDraft(this.draftId, { tags }); + this.actions.updateDraft(this.draftId(), { tags }); } } diff --git a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts index 25350b20b..5fc736156 100644 --- a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts +++ b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts @@ -7,7 +7,7 @@ import { Tree } from 'primeng/tree'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { ProjectShortInfoModel } from '../../models'; +import { ProjectShortInfoModel } from '../../models/project-short-info.model'; @Component({ selector: 'osf-select-components-dialog', @@ -19,6 +19,7 @@ import { ProjectShortInfoModel } from '../../models'; export class SelectComponentsDialogComponent { readonly dialogRef = inject(DynamicDialogRef); readonly config = inject(DynamicDialogConfig); + selectedComponents: TreeNode[] = []; parent: ProjectShortInfoModel = this.config.data.parent; components: TreeNode[] = []; @@ -37,10 +38,14 @@ export class SelectComponentsDialogComponent { this.selectedComponents.push({ key: this.parent.id }); } + continue() { + const selectedComponentsSet = new Set([...this.selectedComponents.map((c) => c.key!), this.parent.id]); + this.dialogRef.close([...selectedComponentsSet]); + } + private mapProjectToTreeNode = (project: ProjectShortInfoModel): TreeNode => { - this.selectedComponents.push({ - key: project.id, - }); + this.selectedComponents.push({ key: project.id }); + return { label: project.title, data: project.id, @@ -49,9 +54,4 @@ export class SelectComponentsDialogComponent { children: project.children?.map(this.mapProjectToTreeNode) ?? [], }; }; - - continue() { - const selectedComponentsSet = new Set([...this.selectedComponents.map((c) => c.key!), this.parent.id]); - this.dialogRef.close([...selectedComponentsSet]); - } } diff --git a/src/app/features/registries/models/index.ts b/src/app/features/registries/models/index.ts deleted file mode 100644 index 101e1aeac..000000000 --- a/src/app/features/registries/models/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './project-short-info.model'; diff --git a/src/app/features/registries/store/handlers/projects.handlers.ts b/src/app/features/registries/store/handlers/projects.handlers.ts index 9738d8442..88562a02a 100644 --- a/src/app/features/registries/store/handlers/projects.handlers.ts +++ b/src/app/features/registries/store/handlers/projects.handlers.ts @@ -5,7 +5,7 @@ import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { ProjectsService } from '@osf/shared/services/projects.service'; -import { ProjectShortInfoModel } from '../../models'; +import { ProjectShortInfoModel } from '../../models/project-short-info.model'; import { REGISTRIES_STATE_DEFAULTS, RegistriesStateModel } from '../registries.model'; @Injectable() diff --git a/src/app/features/registries/store/registries.model.ts b/src/app/features/registries/store/registries.model.ts index f83ee5291..4542aaa96 100644 --- a/src/app/features/registries/store/registries.model.ts +++ b/src/app/features/registries/store/registries.model.ts @@ -11,7 +11,7 @@ import { ResourceModel } from '@osf/shared/models/search/resource.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; -import { ProjectShortInfoModel } from '../models'; +import { ProjectShortInfoModel } from '../models/project-short-info.model'; export interface RegistriesStateModel { providerSchemas: AsyncStateModel; diff --git a/src/app/features/registries/store/registries.selectors.ts b/src/app/features/registries/store/registries.selectors.ts index 3335b9191..e75242bf2 100644 --- a/src/app/features/registries/store/registries.selectors.ts +++ b/src/app/features/registries/store/registries.selectors.ts @@ -1,5 +1,6 @@ import { Selector } from '@ngxs/store'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { LicenseModel } from '@osf/shared/models/license/license.model'; @@ -11,7 +12,7 @@ import { RegistrationCard } from '@osf/shared/models/registration/registration-c import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; import { ResourceModel } from '@osf/shared/models/search/resource.model'; -import { ProjectShortInfoModel } from '../models'; +import { ProjectShortInfoModel } from '../models/project-short-info.model'; import { RegistriesStateModel } from './registries.model'; import { RegistriesState } from './registries.state'; @@ -52,6 +53,11 @@ export class RegistriesSelectors { return state.draftRegistration.data; } + @Selector([RegistriesState]) + static hasDraftAdminAccess(state: RegistriesStateModel): boolean { + return state.draftRegistration.data?.currentUserPermissions?.includes(UserPermissions.Admin) || false; + } + @Selector([RegistriesState]) static getRegistrationLoading(state: RegistriesStateModel): boolean { return state.draftRegistration.isLoading || state.draftRegistration.isSubmitting || state.pagesSchema.isLoading; diff --git a/src/app/features/registries/store/registries.state.ts b/src/app/features/registries/store/registries.state.ts index 6a8ae7120..d24602bbf 100644 --- a/src/app/features/registries/store/registries.state.ts +++ b/src/app/features/registries/store/registries.state.ts @@ -54,11 +54,11 @@ import { REGISTRIES_STATE_DEFAULTS, RegistriesStateModel } from './registries.mo }) @Injectable() export class RegistriesState { - searchService = inject(GlobalSearchService); - registriesService = inject(RegistriesService); private readonly environment = inject(ENVIRONMENT); private readonly store = inject(Store); + searchService = inject(GlobalSearchService); + registriesService = inject(RegistriesService); providersHandler = inject(ProvidersHandlers); projectsHandler = inject(ProjectsHandlers); licensesHandler = inject(LicensesHandlers); @@ -238,7 +238,7 @@ export class RegistriesState { }, }); }), - catchError((error) => handleSectionError(ctx, 'draftRegistration', error)) + catchError((error) => handleSectionError(ctx, 'registration', error)) ); } From b6e7d56bf62e4e10b0b83fcb3468efa95682179f Mon Sep 17 00:00:00 2001 From: mkovalua Date: Tue, 17 Feb 2026 18:36:42 +0200 Subject: [PATCH 13/27] [ENG-9043] v2 (#878) - Ticket: https://openscience.atlassian.net/browse/ENG-9042?focusedCommentId=104663 - Feature flag: n/a ## Purpose set default provider license on project edit in the following tab http://localhost:4200/collections/flubber/add http://localhost:4200/collections/{provider/add --- .../project-metadata-step.component.ts | 6 ++++-- .../add-project-form/add-project-form.component.spec.ts | 2 +- src/app/shared/mappers/collections/collections.mapper.ts | 1 + src/app/shared/models/collections/collections.model.ts | 1 + .../shared/models/provider/base-provider-json-api.model.ts | 1 + 5 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts index 2fd2b0b4c..878893086 100644 --- a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts @@ -45,6 +45,7 @@ import { InterpolatePipe } from '@osf/shared/pipes/interpolate.pipe'; import { ToastService } from '@osf/shared/services/toast.service'; import { GetAllContributors } from '@osf/shared/stores/contributors'; import { ClearProjects, ProjectsSelectors, UpdateProjectMetadata } from '@osf/shared/stores/projects'; +import { CollectionsSelectors } from '@shared/stores/collections'; @Component({ selector: 'osf-project-metadata-step', @@ -86,6 +87,7 @@ export class ProjectMetadataStepComponent { readonly inputLimits = InputLimits; readonly selectedProject = select(ProjectsSelectors.getSelectedProject); + readonly collectionProvider = select(CollectionsSelectors.getCollectionProvider); readonly collectionLicenses = select(AddToCollectionSelectors.getCollectionLicenses); readonly isSelectedProjectUpdateSubmitting = select(ProjectsSelectors.getSelectedProjectUpdateSubmitting); @@ -113,7 +115,8 @@ export class ProjectMetadataStepComponent { readonly projectLicense = computed(() => { const project = this.selectedProject(); - return project ? (this.collectionLicenses().find((license) => license.id === project.licenseId) ?? null) : null; + const licenseId = project?.licenseId || this.collectionProvider()?.defaultLicenseId; + return project ? (this.collectionLicenses().find((license) => license.id === licenseId) ?? null) : null; }); private readonly isFormUnchanged = computed(() => { @@ -235,7 +238,6 @@ export class ProjectMetadataStepComponent { this.formService.updateLicenseValidators(this.projectMetadataForm, license); }); } - this.populateFormFromProject(); }); diff --git a/src/app/shared/components/add-project-form/add-project-form.component.spec.ts b/src/app/shared/components/add-project-form/add-project-form.component.spec.ts index ee325c8f8..cdaf249de 100644 --- a/src/app/shared/components/add-project-form/add-project-form.component.spec.ts +++ b/src/app/shared/components/add-project-form/add-project-form.component.spec.ts @@ -9,11 +9,11 @@ import { FormControl, FormGroup, Validators } from '@angular/forms'; import { UserSelectors } from '@core/store/user'; import { ProjectFormControls } from '@osf/shared/enums/create-project-form-controls.enum'; import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; +import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { InstitutionsSelectors } from '@osf/shared/stores/institutions'; import { ProjectsSelectors } from '@osf/shared/stores/projects'; import { RegionsSelectors } from '@osf/shared/stores/regions'; import { ProjectForm } from '@shared/models/projects/create-project-form.model'; -import { ProjectModel } from '@shared/models/projects/projects.models'; import { AffiliatedInstitutionSelectComponent } from '../affiliated-institution-select/affiliated-institution-select.component'; import { ProjectSelectorComponent } from '../project-selector/project-selector.component'; diff --git a/src/app/shared/mappers/collections/collections.mapper.ts b/src/app/shared/mappers/collections/collections.mapper.ts index 227fbe021..680b34a04 100644 --- a/src/app/shared/mappers/collections/collections.mapper.ts +++ b/src/app/shared/mappers/collections/collections.mapper.ts @@ -58,6 +58,7 @@ export class CollectionsMapper { id: response.relationships.primary_collection.data.id, type: response.relationships.primary_collection.data.type, }, + defaultLicenseId: response.attributes?.default_license_id, brand: response.embeds.brand.data ? { id: response.embeds.brand.data.id, diff --git a/src/app/shared/models/collections/collections.model.ts b/src/app/shared/models/collections/collections.model.ts index 5b27a3bff..6b67d7d16 100644 --- a/src/app/shared/models/collections/collections.model.ts +++ b/src/app/shared/models/collections/collections.model.ts @@ -18,6 +18,7 @@ export interface CollectionProvider extends BaseProviderModel { type: string; }; brand: BrandModel | null; + defaultLicenseId?: string | null; } export interface CollectionFilters { diff --git a/src/app/shared/models/provider/base-provider-json-api.model.ts b/src/app/shared/models/provider/base-provider-json-api.model.ts index 29ab8c054..27bce5d3d 100644 --- a/src/app/shared/models/provider/base-provider-json-api.model.ts +++ b/src/app/shared/models/provider/base-provider-json-api.model.ts @@ -16,4 +16,5 @@ export interface BaseProviderAttributesJsonApi { reviews_workflow: string; share_publish_type: string; share_source: string; + default_license_id?: string | null; } From 2a6b2f1b17b35aea4b5df7fa0d08fc09844786c8 Mon Sep 17 00:00:00 2001 From: nsemets Date: Tue, 17 Feb 2026 21:50:16 +0200 Subject: [PATCH 14/27] [ENG-10255] Part 3: Added unit tests for pages components in registries (#885) - Ticket: [ENG-10255] - Feature flag: n/a ## Summary of Changes 1. Added unit tests for components in registries. --- .../my-projects/my-projects.component.spec.ts | 4 +- .../overview-collections.component.spec.ts | 2 +- ...irm-continue-editing-dialog.component.html | 4 +- ...-continue-editing-dialog.component.spec.ts | 120 ++--- ...nfirm-continue-editing-dialog.component.ts | 14 +- ...firm-registration-dialog.component.spec.ts | 101 ++-- .../confirm-registration-dialog.component.ts | 41 +- .../custom-step/custom-step.component.spec.ts | 262 +++++++++- .../custom-step/custom-step.component.ts | 261 +++++----- .../drafts/drafts.component.spec.ts | 450 +++++++++++++++-- .../components/drafts/drafts.component.ts | 214 ++++---- .../files-control.component.html | 6 +- .../files-control.component.spec.ts | 236 +++++++-- .../files-control/files-control.component.ts | 149 +++--- .../justification-review.component.html | 4 +- .../justification-review.component.spec.ts | 112 +++-- .../justification-review.component.ts | 97 ++-- .../justification-step.component.html | 4 +- .../justification-step.component.spec.ts | 107 ++-- .../justification-step.component.ts | 90 ++-- .../new-registration.component.html | 14 +- .../new-registration.component.spec.ts | 133 +++-- .../new-registration.component.ts | 124 ++--- ...registries-metadata-step.component.spec.ts | 28 +- .../registry-provider-hero.component.spec.ts | 94 +++- .../registry-services.component.spec.ts | 14 +- .../components/review/review.component.html | 10 +- .../review/review.component.spec.ts | 476 +++++++++++++++--- .../components/review/review.component.ts | 113 +++-- .../select-components-dialog.component.html | 4 +- ...select-components-dialog.component.spec.ts | 43 +- .../registries/models/attached-file.model.ts | 3 + ...registration-custom-step.component.spec.ts | 11 +- .../justification.component.spec.ts | 17 +- .../my-registrations.component.spec.ts | 21 +- .../registries-landing.component.spec.ts | 7 +- ...gistries-provider-search.component.spec.ts | 7 +- .../revisions-custom-step.component.spec.ts | 11 +- src/assets/i18n/en.json | 2 +- src/testing/mocks/data.mock.ts | 1 + src/testing/osf.testing.provider.ts | 26 - .../providers/component-provider.mock.ts | 1 - 42 files changed, 2270 insertions(+), 1168 deletions(-) create mode 100644 src/app/features/registries/models/attached-file.model.ts diff --git a/src/app/features/my-projects/my-projects.component.spec.ts b/src/app/features/my-projects/my-projects.component.spec.ts index d3d9da18f..5c1caaab4 100644 --- a/src/app/features/my-projects/my-projects.component.spec.ts +++ b/src/app/features/my-projects/my-projects.component.spec.ts @@ -48,12 +48,12 @@ describe('MyProjectsComponent', () => { { selector: MyResourcesSelectors.getTotalProjects, value: 0 }, { selector: MyResourcesSelectors.getTotalRegistrations, value: 0 }, { selector: MyResourcesSelectors.getTotalPreprints, value: 0 }, - { selector: MyResourcesSelectors.getTotalBookmarks, value: 0 }, + { selector: BookmarksSelectors.getBookmarksTotalCount, value: 0 }, { selector: BookmarksSelectors.getBookmarksCollectionId, value: null }, { selector: MyResourcesSelectors.getProjects, value: [] }, { selector: MyResourcesSelectors.getRegistrations, value: [] }, { selector: MyResourcesSelectors.getPreprints, value: [] }, - { selector: MyResourcesSelectors.getBookmarks, value: [] }, + { selector: BookmarksSelectors.getBookmarks, value: [] }, ], }), { provide: ActivatedRoute, useValue: mockActivatedRoute }, diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts b/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts index 4809b1a72..3ac5256fb 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { collectionFilterNames } from '@osf/features/collections/constants'; -import { CollectionSubmission } from '@osf/shared/models/collections/collections.models'; +import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; import { OverviewCollectionsComponent } from './overview-collections.component'; diff --git a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.html b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.html index 8a74c891e..9608fd083 100644 --- a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.html +++ b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.html @@ -10,13 +10,13 @@ class="w-12rem btn-full-width" [label]="'common.buttons.cancel' | translate" severity="info" - (click)="dialogRef.close()" + (onClick)="dialogRef.close()" />
diff --git a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.spec.ts b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.spec.ts index d3130fff6..afb73a74e 100644 --- a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.spec.ts +++ b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.spec.ts @@ -1,46 +1,42 @@ +import { Store } from '@ngxs/store'; + import { MockProvider } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { of } from 'rxjs'; - import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SchemaActionTrigger } from '@osf/features/registries/enums'; +import { HandleSchemaResponse } from '@osf/features/registries/store'; import { ConfirmContinueEditingDialogComponent } from './confirm-continue-editing-dialog.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('ConfirmContinueEditingDialogComponent', () => { let component: ConfirmContinueEditingDialogComponent; let fixture: ComponentFixture; - let mockDialogRef: DynamicDialogRef; - let mockDialogConfig: jest.Mocked; + let store: Store; + let dialogRef: DynamicDialogRef; const MOCK_REVISION_ID = 'test-revision-id'; - beforeEach(async () => { - mockDialogRef = { - close: jest.fn(), - } as any; - - mockDialogConfig = { - data: { revisionId: MOCK_REVISION_ID }, - } as jest.Mocked; - - await TestBed.configureTestingModule({ - imports: [ConfirmContinueEditingDialogComponent, OSFTestingModule], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ConfirmContinueEditingDialogComponent], providers: [ - MockProvider(DynamicDialogRef, mockDialogRef), - MockProvider(DynamicDialogConfig, mockDialogConfig), - provideMockStore({ - signals: [], - }), + provideOSFCore(), + provideDynamicDialogRefMock(), + // MockProvider(DynamicDialogRef), + MockProvider(DynamicDialogConfig, { data: { revisionId: MOCK_REVISION_ID } }), + provideMockStore(), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); + dialogRef = TestBed.inject(DynamicDialogRef); fixture = TestBed.createComponent(ConfirmContinueEditingDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -54,87 +50,27 @@ describe('ConfirmContinueEditingDialogComponent', () => { expect(component.isSubmitting).toBe(false); }); - it('should submit with comment', () => { - const testComment = 'Test comment'; - component.form.patchValue({ comment: testComment }); - - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({})), - }; - - Object.defineProperty(component, 'actions', { - value: mockActions, - writable: true, - }); + it('should dispatch handleSchemaResponse with comment on submit', () => { + component.form.patchValue({ comment: 'Test comment' }); component.submit(); - expect(mockActions.handleSchemaResponse).toHaveBeenCalledWith( - MOCK_REVISION_ID, - SchemaActionTrigger.AdminReject, - testComment + expect(store.dispatch).toHaveBeenCalledWith( + new HandleSchemaResponse(MOCK_REVISION_ID, SchemaActionTrigger.AdminReject, 'Test comment') ); + expect(dialogRef.close).toHaveBeenCalledWith(true); }); - it('should submit with empty comment', () => { - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({})), - }; - - Object.defineProperty(component, 'actions', { - value: mockActions, - writable: true, - }); - + it('should dispatch handleSchemaResponse with empty comment on submit', () => { component.submit(); - expect(mockActions.handleSchemaResponse).toHaveBeenCalledWith( - MOCK_REVISION_ID, - SchemaActionTrigger.AdminReject, - '' + expect(store.dispatch).toHaveBeenCalledWith( + new HandleSchemaResponse(MOCK_REVISION_ID, SchemaActionTrigger.AdminReject, '') ); }); - it('should set isSubmitting to true when submitting', () => { - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({}).pipe()), - }; - - Object.defineProperty(component, 'actions', { - value: mockActions, - writable: true, - }); - - component.submit(); - expect(mockActions.handleSchemaResponse).toHaveBeenCalled(); - }); - it('should update comment value', () => { - const testComment = 'New comment'; - component.form.patchValue({ comment: testComment }); - - expect(component.form.get('comment')?.value).toBe(testComment); - }); - - it('should handle different revision IDs', () => { - const differentRevisionId = 'different-revision-id'; - (component as any).config.data = { revisionId: differentRevisionId } as any; - - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({})), - }; - - Object.defineProperty(component, 'actions', { - value: mockActions, - writable: true, - }); - - component.submit(); - - expect(mockActions.handleSchemaResponse).toHaveBeenCalledWith( - differentRevisionId, - SchemaActionTrigger.AdminReject, - '' - ); + component.form.patchValue({ comment: 'New comment' }); + expect(component.form.get('comment')?.value).toBe('New comment'); }); }); diff --git a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.ts b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.ts index 87b307f68..b24087d4e 100644 --- a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.ts +++ b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.ts @@ -23,20 +23,16 @@ import { HandleSchemaResponse } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ConfirmContinueEditingDialogComponent { - readonly dialogRef = inject(DynamicDialogRef); - private readonly fb = inject(FormBuilder); readonly config = inject(DynamicDialogConfig); - private readonly destroyRef = inject(DestroyRef); + readonly dialogRef = inject(DynamicDialogRef); + readonly destroyRef = inject(DestroyRef); + readonly fb = inject(FormBuilder); - actions = createDispatchMap({ - handleSchemaResponse: HandleSchemaResponse, - }); + actions = createDispatchMap({ handleSchemaResponse: HandleSchemaResponse }); isSubmitting = false; - form: FormGroup = this.fb.group({ - comment: [''], - }); + form: FormGroup = this.fb.group({ comment: [''] }); submit(): void { const comment = this.form.value.comment; diff --git a/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.spec.ts b/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.spec.ts index 3cd46f5fb..781dbc459 100644 --- a/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.spec.ts +++ b/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.spec.ts @@ -1,47 +1,50 @@ +import { Store } from '@ngxs/store'; + import { MockProvider } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { of } from 'rxjs'; +import { throwError } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SubmitType } from '@osf/features/registries/enums'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { RegisterDraft, RegistriesSelectors } from '@osf/features/registries/store'; import { ConfirmRegistrationDialogComponent } from './confirm-registration-dialog.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('ConfirmRegistrationDialogComponent', () => { let component: ConfirmRegistrationDialogComponent; let fixture: ComponentFixture; - let mockDialogRef: DynamicDialogRef; - let mockDialogConfig: jest.Mocked; + let store: Store; + let dialogRef: DynamicDialogRef; const MOCK_CONFIG_DATA = { draftId: 'draft-1', providerId: 'provider-1', projectId: 'project-1', - components: [], + components: [] as string[], }; - beforeEach(async () => { - mockDialogRef = { close: jest.fn() } as any; - mockDialogConfig = { data: { ...MOCK_CONFIG_DATA } } as any; - - await TestBed.configureTestingModule({ - imports: [ConfirmRegistrationDialogComponent, OSFTestingModule], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ConfirmRegistrationDialogComponent], providers: [ - MockProvider(DynamicDialogRef, mockDialogRef), - MockProvider(DynamicDialogConfig, mockDialogConfig), + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { data: { ...MOCK_CONFIG_DATA } }), provideMockStore({ signals: [{ selector: RegistriesSelectors.isRegistrationSubmitting, value: false }], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); + dialogRef = TestBed.inject(DynamicDialogRef); fixture = TestBed.createComponent(ConfirmRegistrationDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -78,44 +81,60 @@ describe('ConfirmRegistrationDialogComponent', () => { expect(embargoControl?.value).toBeNull(); }); - it('should submit with immediate option and close dialog', () => { - const mockActions = { - registerDraft: jest.fn().mockReturnValue(of({})), - }; - Object.defineProperty(component, 'actions', { value: mockActions, writable: true }); - + it('should dispatch registerDraft with immediate option and close dialog', () => { component.form.get('submitOption')?.setValue(SubmitType.Public); + component.submit(); - expect(mockActions.registerDraft).toHaveBeenCalledWith( - MOCK_CONFIG_DATA.draftId, - '', - MOCK_CONFIG_DATA.providerId, - MOCK_CONFIG_DATA.projectId, - MOCK_CONFIG_DATA.components + expect(store.dispatch).toHaveBeenCalledWith( + new RegisterDraft( + MOCK_CONFIG_DATA.draftId, + '', + MOCK_CONFIG_DATA.providerId, + MOCK_CONFIG_DATA.projectId, + MOCK_CONFIG_DATA.components + ) ); - expect(mockDialogRef.close).toHaveBeenCalledWith(true); + expect(dialogRef.close).toHaveBeenCalledWith(true); }); - it('should submit with embargo and include ISO embargoDate', () => { - const mockActions = { - registerDraft: jest.fn().mockReturnValue(of({})), - }; - Object.defineProperty(component, 'actions', { value: mockActions, writable: true }); - + it('should dispatch registerDraft with embargo and include ISO embargoDate', () => { const date = new Date('2025-01-01T00:00:00Z'); component.form.get('submitOption')?.setValue(SubmitType.Embargo); component.form.get('embargoDate')?.setValue(date); component.submit(); - expect(mockActions.registerDraft).toHaveBeenCalledWith( - MOCK_CONFIG_DATA.draftId, - date.toISOString(), - MOCK_CONFIG_DATA.providerId, - MOCK_CONFIG_DATA.projectId, - MOCK_CONFIG_DATA.components + expect(store.dispatch).toHaveBeenCalledWith( + new RegisterDraft( + MOCK_CONFIG_DATA.draftId, + date.toISOString(), + MOCK_CONFIG_DATA.providerId, + MOCK_CONFIG_DATA.projectId, + MOCK_CONFIG_DATA.components + ) ); - expect(mockDialogRef.close).toHaveBeenCalledWith(true); + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + + it('should return a date 3 days in the future for minEmbargoDate', () => { + const expected = new Date(); + expected.setDate(expected.getDate() + 3); + + const result = component.minEmbargoDate(); + + expect(result.getFullYear()).toBe(expected.getFullYear()); + expect(result.getMonth()).toBe(expected.getMonth()); + expect(result.getDate()).toBe(expected.getDate()); + }); + + it('should re-enable form on submit error', () => { + (store.dispatch as jest.Mock).mockReturnValueOnce(throwError(() => new Error('fail'))); + + component.form.get('submitOption')?.setValue(SubmitType.Public); + component.submit(); + + expect(component.form.enabled).toBe(true); + expect(dialogRef.close).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.ts b/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.ts index 874b1896f..56ee36981 100644 --- a/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.ts +++ b/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.ts @@ -7,7 +7,8 @@ import { DatePicker } from 'primeng/datepicker'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { RadioButton } from 'primeng/radiobutton'; -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { SubmitType } from '../../enums'; @@ -21,14 +22,13 @@ import { RegisterDraft, RegistriesSelectors } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ConfirmRegistrationDialogComponent { - readonly dialogRef = inject(DynamicDialogRef); - private readonly fb = inject(FormBuilder); readonly config = inject(DynamicDialogConfig); + readonly dialogRef = inject(DynamicDialogRef); + readonly destroyRef = inject(DestroyRef); + readonly fb = inject(FormBuilder); readonly isRegistrationSubmitting = select(RegistriesSelectors.isRegistrationSubmitting); - actions = createDispatchMap({ - registerDraft: RegisterDraft, - }); + actions = createDispatchMap({ registerDraft: RegisterDraft }); SubmitType = SubmitType; showDateControl = false; minEmbargoDate = computed(() => { @@ -43,21 +43,24 @@ export class ConfirmRegistrationDialogComponent { }); constructor() { - this.form.get('submitOption')!.valueChanges.subscribe((value) => { - this.showDateControl = value === SubmitType.Embargo; - const dateControl = this.form.get('embargoDate'); + this.form + .get('submitOption') + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + this.showDateControl = value === SubmitType.Embargo; + const dateControl = this.form.get('embargoDate'); - if (this.showDateControl) { - dateControl!.enable(); - dateControl!.setValidators(Validators.required); - } else { - dateControl!.disable(); - dateControl!.clearValidators(); - dateControl!.reset(); - } + if (this.showDateControl) { + dateControl!.enable(); + dateControl!.setValidators(Validators.required); + } else { + dateControl!.disable(); + dateControl!.clearValidators(); + dateControl!.reset(); + } - dateControl!.updateValueAndValidity(); - }); + dateControl!.updateValueAndValidity(); + }); } submit(): void { diff --git a/src/app/features/registries/components/custom-step/custom-step.component.spec.ts b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts index 1b287987e..a354ad6b6 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.spec.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts @@ -1,50 +1,92 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; +import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormGroup } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { InfoIconComponent } from '@osf/shared/components/info-icon/info-icon.component'; +import { FieldType } from '@osf/shared/enums/field-type.enum'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { FileModel } from '@shared/models/files/file.model'; +import { FilePayloadJsonApi } from '@shared/models/files/file-payload-json-api.model'; +import { PageSchema } from '@shared/models/registration/page-schema.model'; -import { RegistriesSelectors } from '../../store'; +import { RegistriesSelectors, SetUpdatedFields, UpdateStepState } from '../../store'; import { FilesControlComponent } from '../files-control/files-control.component'; import { CustomStepComponent } from './custom-step.component'; import { MOCK_REGISTRIES_PAGE, MOCK_STEPS_DATA } from '@testing/mocks/registries.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'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +type StepsState = Record; describe('CustomStepComponent', () => { let component: CustomStepComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; + let store: Store; + let routeBuilder: ActivatedRouteMockBuilder; + let mockRouter: RouterMockType; + let toastMock: ToastServiceMockType; + let pagesSignal: WritableSignal; + let stepsStateSignal: WritableSignal; - const MOCK_PAGE = MOCK_REGISTRIES_PAGE; + function createComponent( + page: PageSchema, + stepsData: Record = {}, + stepsState: StepsState = {} + ): ComponentFixture { + pagesSignal.set([page]); + stepsStateSignal.set(stepsState); + const f = TestBed.createComponent(CustomStepComponent); + f.componentRef.setInput('stepsData', stepsData); + f.componentRef.setInput('filesLink', 'files-link'); + f.componentRef.setInput('projectId', 'project'); + f.componentRef.setInput('provider', 'provider'); + f.detectChanges(); + return f; + } - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ step: 1 }).build(); + function createPage( + questions: PageSchema['questions'] = [], + sections: PageSchema['sections'] = undefined + ): PageSchema { + return { id: 'p', title: 'P', questions, sections }; + } + + beforeEach(() => { + toastMock = ToastServiceMock.simple(); + routeBuilder = ActivatedRouteMockBuilder.create().withParams({ step: 1 }); mockRouter = RouterMockBuilder.create().withUrl('/registries/drafts/id/1').build(); + pagesSignal = signal([MOCK_REGISTRIES_PAGE]); + stepsStateSignal = signal({}); - await TestBed.configureTestingModule({ - imports: [CustomStepComponent, OSFTestingModule, ...MockComponents(InfoIconComponent, FilesControlComponent)], + TestBed.configureTestingModule({ + imports: [CustomStepComponent, ...MockComponents(InfoIconComponent, FilesControlComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), + provideOSFCore(), + MockProvider(ToastService, toastMock), + MockProvider(ActivatedRoute, routeBuilder.build()), MockProvider(Router, mockRouter), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getPagesSchema, value: [MOCK_PAGE] }, - { selector: RegistriesSelectors.getStepsState, value: {} }, + { selector: RegistriesSelectors.getPagesSchema, value: pagesSignal }, + { selector: RegistriesSelectors.getStepsState, value: stepsStateSignal }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(CustomStepComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('stepsData', MOCK_STEPS_DATA); fixture.componentRef.setInput('filesLink', 'files-link'); fixture.componentRef.setInput('projectId', 'project'); @@ -57,20 +99,200 @@ describe('CustomStepComponent', () => { }); it('should initialize stepForm when page available', () => { - expect(component['stepForm']).toBeDefined(); expect(Object.keys(component['stepForm'].controls)).toContain('field1'); expect(Object.keys(component['stepForm'].controls)).toContain('field2'); }); - it('should navigate back when goBack called on first step', () => { + it('should emit back on first step', () => { const backSpy = jest.spyOn(component.back, 'emit'); component.goBack(); expect(backSpy).toHaveBeenCalled(); }); - it('should navigate next when goNext called with within pages', () => { - Object.defineProperty(component, 'pages', { value: () => [MOCK_REGISTRIES_PAGE, MOCK_REGISTRIES_PAGE] }); + it('should navigate to previous step on step > 1', () => { + component.step.set(2); + component.goBack(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['../', 1], { relativeTo: expect.anything() }); + }); + + it('should navigate to next step within pages', () => { + pagesSignal.set([MOCK_REGISTRIES_PAGE, MOCK_REGISTRIES_PAGE]); component.goNext(); - expect(mockRouter.navigate).toHaveBeenCalled(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['../', 2], { relativeTo: expect.anything() }); + }); + + it('should emit next on last step', () => { + const nextSpy = jest.spyOn(component.next, 'emit'); + component.step.set(1); + component.goNext(); + expect(nextSpy).toHaveBeenCalled(); + }); + + it('should dispatch updateStepState on ngOnDestroy', () => { + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(UpdateStepState)); + }); + + it('should emit updateAction and dispatch setUpdatedFields when fields changed', () => { + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + component['stepForm'].get('field1')?.setValue('changed'); + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(emitSpy).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new SetUpdatedFields({ field1: 'changed' })); + }); + + it('should not emit updateAction when no fields changed', () => { + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(emitSpy).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetUpdatedFields)); + }); + + it('should skip saveStepState when form has no controls', () => { + component.stepForm = new FormGroup({}); + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should attach file and emit updateAction', () => { + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + const mockFile = { + id: 'new-file', + name: 'new.txt', + links: { html: 'http://html', download: 'http://dl' }, + extra: { hashes: { sha256: 'abc' } }, + } as FileModel; + + component.onAttachFile(mockFile, 'field1'); + + expect(component.attachedFiles['field1'].length).toBe(1); + expect(emitSpy).toHaveBeenCalled(); + expect(emitSpy.mock.calls[0][0]['field1'][0].file_id).toBe('new-file'); + }); + + it('should not attach duplicate file', () => { + component.attachedFiles['field1'] = [{ file_id: 'file-1', name: 'existing.txt' }]; + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + + component.onAttachFile({ id: 'file-1' } as FileModel, 'field1'); + + expect(component.attachedFiles['field1'].length).toBe(1); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should show warning when attachment limit reached', () => { + component.attachedFiles['field1'] = Array.from({ length: 5 }, (_, i) => ({ file_id: `f-${i}`, name: `f-${i}` })); + + const mockFile = { + id: 'new', + name: 'new.txt', + links: { html: '', download: '' }, + extra: { hashes: { sha256: '', md5: '' } }, + } as FileModel; + component.onAttachFile(mockFile, 'field1'); + + expect(toastMock.showWarn).toHaveBeenCalledWith('shared.files.limitText'); + expect(component.attachedFiles['field1'].length).toBe(5); + }); + + it('should remove file and emit updateAction', () => { + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + component.attachedFiles['field1'] = [ + { file_id: 'f1', name: 'a' }, + { file_id: 'f2', name: 'b' }, + ]; + + component.removeFromAttachedFiles({ file_id: 'f1', name: 'a' }, 'field1'); + + expect(component.attachedFiles['field1'].length).toBe(1); + expect(component.attachedFiles['field1'][0].file_id).toBe('f2'); + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should skip non-existent questionKey', () => { + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + component.removeFromAttachedFiles({ file_id: 'f1' }, 'nonexistent'); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should save step state and update step on route param change', () => { + (store.dispatch as jest.Mock).mockClear(); + routeBuilder.withParams({ step: 2 }); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(UpdateStepState)); + expect(component.step()).toBe(2); + }); + + it('should mark form touched when stepsState has invalid for current step', () => { + const f = createComponent(MOCK_REGISTRIES_PAGE, MOCK_STEPS_DATA, { + 1: { invalid: true, touched: true }, + }); + expect(f.componentInstance['stepForm'].get('field1')?.touched).toBe(true); + }); + + it('should initialize checkbox control with empty array default', () => { + const page = createPage([ + { id: 'q', displayText: '', responseKey: 'cbField', fieldType: FieldType.Checkbox, required: true }, + ]); + const f = createComponent(page); + expect(f.componentInstance['stepForm'].get('cbField')?.value).toEqual([]); + }); + + it('should initialize radio control with required validator', () => { + const page = createPage([ + { id: 'q', displayText: '', responseKey: 'radioField', fieldType: FieldType.Radio, required: true }, + ]); + const f = createComponent(page); + expect(f.componentInstance['stepForm'].get('radioField')?.valid).toBe(false); + }); + + it('should initialize file control and populate attachedFiles', () => { + const page = createPage([ + { id: 'q', displayText: '', responseKey: 'fileField', fieldType: FieldType.File, required: false }, + ]); + const files: FilePayloadJsonApi[] = [ + { file_id: 'f1', file_name: 'doc.pdf', file_urls: { html: '', download: '' }, file_hashes: { sha256: '' } }, + ]; + const f = createComponent(page, { fileField: files }); + + expect(f.componentInstance.attachedFiles['fileField'].length).toBe(1); + expect(f.componentInstance.attachedFiles['fileField'][0].name).toBe('doc.pdf'); + }); + + it('should skip unknown field types', () => { + const page = createPage([ + { id: 'q', displayText: '', responseKey: 'unknownField', fieldType: 'unknown' as FieldType, required: false }, + ]); + const f = createComponent(page); + expect(f.componentInstance['stepForm'].get('unknownField')).toBeNull(); + }); + + it('should include section questions', () => { + const page = createPage( + [], + [ + { + id: 's1', + title: 'S', + questions: [ + { id: 'q', displayText: '', responseKey: 'secField', fieldType: FieldType.Text, required: false }, + ], + }, + ] + ); + const f = createComponent(page, { secField: 'val' }); + + expect(f.componentInstance['stepForm'].get('secField')).toBeDefined(); + expect(f.componentInstance['stepForm'].get('secField')?.value).toBe('val'); }); }); diff --git a/src/app/features/registries/components/custom-step/custom-step.component.ts b/src/app/features/registries/components/custom-step/custom-step.component.ts index 98900e02c..357bc71b5 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.ts @@ -26,7 +26,7 @@ import { signal, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { InfoIconComponent } from '@osf/shared/components/info-icon/info-icon.component'; @@ -41,28 +41,27 @@ import { FilePayloadJsonApi } from '@shared/models/files/file-payload-json-api.m import { PageSchema } from '@shared/models/registration/page-schema.model'; import { FilesMapper } from '../../mappers/files.mapper'; +import { AttachedFile } from '../../models/attached-file.model'; import { RegistriesSelectors, SetUpdatedFields, UpdateStepState } from '../../store'; import { FilesControlComponent } from '../files-control/files-control.component'; @Component({ selector: 'osf-custom-step', imports: [ + Button, Card, - Textarea, - RadioButton, - FormsModule, Checkbox, - TranslatePipe, + Chip, + Inplace, InputText, + Message, + RadioButton, + Textarea, + ReactiveFormsModule, NgTemplateOutlet, - Inplace, - TranslatePipe, InfoIconComponent, - Button, - ReactiveFormsModule, - Message, FilesControlComponent, - Chip, + TranslatePipe, ], templateUrl: './custom-step.component.html', styleUrl: './custom-step.component.scss', @@ -80,38 +79,100 @@ export class CustomStepComponent implements OnDestroy { updateAction = output>(); back = output(); next = output(); + private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly fb = inject(FormBuilder); private readonly destroyRef = inject(DestroyRef); - private toastService = inject(ToastService); + private readonly toastService = inject(ToastService); readonly pages = select(RegistriesSelectors.getPagesSchema); - readonly FieldType = FieldType; readonly stepsState = select(RegistriesSelectors.getStepsState); - readonly actions = createDispatchMap({ + private readonly actions = createDispatchMap({ updateStepState: UpdateStepState, setUpdatedFields: SetUpdatedFields, }); + readonly FieldType = FieldType; readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; step = signal(this.route.snapshot.params['step']); currentPage = computed(() => this.pages()[this.step() - 1]); - radio = null; + stepForm: FormGroup = this.fb.group({}); + attachedFiles: Record = {}; - stepForm!: FormGroup; + constructor() { + this.setupRouteWatcher(); + this.setupPageFormInit(); + } - attachedFiles: Record[]> = {}; + ngOnDestroy(): void { + this.saveStepState(); + } - constructor() { + onAttachFile(file: FileModel, questionKey: string): void { + this.attachedFiles[questionKey] = this.attachedFiles[questionKey] || []; + + if (this.attachedFiles[questionKey].some((f) => f.file_id === file.id)) { + return; + } + + if (this.attachedFiles[questionKey].length >= FILE_COUNT_ATTACHMENTS_LIMIT) { + this.toastService.showWarn('shared.files.limitText'); + return; + } + + this.attachedFiles[questionKey] = [...this.attachedFiles[questionKey], file]; + this.stepForm.patchValue({ [questionKey]: this.attachedFiles[questionKey] }); + + const otherFormValues = { ...this.stepForm.value }; + delete otherFormValues[questionKey]; + this.updateAction.emit({ + [questionKey]: this.mapFilesToPayload(this.attachedFiles[questionKey]), + ...otherFormValues, + }); + } + + removeFromAttachedFiles(file: AttachedFile, questionKey: string): void { + if (!this.attachedFiles[questionKey]) { + return; + } + + this.attachedFiles[questionKey] = this.attachedFiles[questionKey].filter((f) => f.file_id !== file.file_id); + this.stepForm.patchValue({ [questionKey]: this.attachedFiles[questionKey] }); + this.updateAction.emit({ + [questionKey]: this.mapFilesToPayload(this.attachedFiles[questionKey]), + }); + } + + goBack(): void { + const previousStep = this.step() - 1; + if (previousStep > 0) { + this.router.navigate(['../', previousStep], { relativeTo: this.route }); + } else { + this.back.emit(); + } + } + + goNext(): void { + const nextStep = this.step() + 1; + if (nextStep <= this.pages().length) { + this.router.navigate(['../', nextStep], { relativeTo: this.route }); + } else { + this.next.emit(); + } + } + + private setupRouteWatcher() { this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { - this.updateStepState(); + this.saveStepState(); this.step.set(+params['step']); }); + } + private setupPageFormInit() { effect(() => { const page = this.currentPage(); if (page) { @@ -122,138 +183,78 @@ export class CustomStepComponent implements OnDestroy { private initStepForm(page: PageSchema): void { this.stepForm = this.fb.group({}); - let questions = page.questions || []; - if (page.sections?.length) { - questions = [...questions, ...page.sections.flatMap((section) => section.questions ?? [])]; - } - questions?.forEach((q) => { + const questions = [ + ...(page.questions || []), + ...(page.sections?.flatMap((section) => section.questions ?? []) ?? []), + ]; + + questions.forEach((q) => { const controlName = q.responseKey as string; - let control: FormControl; - - switch (q.fieldType) { - case FieldType.Text: - case FieldType.TextArea: - control = this.fb.control(this.stepsData()[controlName], { - validators: q.required ? [CustomValidators.requiredTrimmed()] : [], - }); - break; - - case FieldType.Checkbox: - control = this.fb.control(this.stepsData()[controlName] || [], { - validators: q.required ? [Validators.required] : [], - }); - break; - - case FieldType.Radio: - case FieldType.Select: - control = this.fb.control(this.stepsData()[controlName], { - validators: q.required ? [Validators.required] : [], - }); - break; - - case FieldType.File: - control = this.fb.control(this.stepsData()[controlName] || [], { - validators: q.required ? [Validators.required] : [], - }); - this.attachedFiles[controlName] = - this.stepsData()[controlName]?.map((file: FilePayloadJsonApi) => ({ ...file, name: file.file_name })) || []; - break; - - default: - return; + const control = this.createControl(q.fieldType!, controlName, q.required); + if (!control) return; + + if (q.fieldType === FieldType.File) { + this.attachedFiles[controlName] = + this.stepsData()[controlName]?.map((file: FilePayloadJsonApi) => ({ ...file, name: file.file_name })) || []; } this.stepForm.addControl(controlName, control); }); + if (this.stepsState()?.[this.step()]?.invalid) { this.stepForm.markAllAsTouched(); } } - private updateDraft() { - const changedFields = findChangedFields(this.stepForm.value, this.stepsData()); - if (Object.keys(changedFields).length > 0) { - this.actions.setUpdatedFields(changedFields); - this.updateAction.emit(this.stepForm.value); - } - } + private createControl(fieldType: FieldType, controlName: string, required: boolean): FormControl | null { + const value = this.stepsData()[controlName]; - private updateStepState() { - if (this.stepForm) { - this.updateDraft(); - this.stepForm.markAllAsTouched(); - this.actions.updateStepState(this.step(), this.stepForm.invalid, true); - } - } + switch (fieldType) { + case FieldType.Text: + case FieldType.TextArea: + return this.fb.control(value, { + validators: required ? [CustomValidators.requiredTrimmed()] : [], + }); - onAttachFile(file: FileModel, questionKey: string): void { - this.attachedFiles[questionKey] = this.attachedFiles[questionKey] || []; + case FieldType.Checkbox: + case FieldType.File: + return this.fb.control(value || [], { + validators: required ? [Validators.required] : [], + }); - if (!this.attachedFiles[questionKey].some((f) => f.file_id === file.id)) { - if (this.attachedFiles[questionKey].length >= FILE_COUNT_ATTACHMENTS_LIMIT) { - this.toastService.showWarn('shared.files.limitText'); - return; - } - this.attachedFiles[questionKey].push(file); - this.stepForm.patchValue({ - [questionKey]: [...(this.attachedFiles[questionKey] || []), file], - }); - const otherFormValues = { ...this.stepForm.value }; - delete otherFormValues[questionKey]; - this.updateAction.emit({ - [questionKey]: [ - ...this.attachedFiles[questionKey].map((f) => { - if (f.file_id) { - const { name: _, ...payload } = f; - return payload; - } - return FilesMapper.toFilePayload(f as FileModel); - }), - ], - ...otherFormValues, - }); - } - } + case FieldType.Radio: + case FieldType.Select: + return this.fb.control(value, { + validators: required ? [Validators.required] : [], + }); - removeFromAttachedFiles(file: Partial, questionKey: string): void { - if (this.attachedFiles[questionKey]) { - this.attachedFiles[questionKey] = this.attachedFiles[questionKey].filter((f) => f.file_id !== file.file_id); - this.stepForm.patchValue({ - [questionKey]: this.attachedFiles[questionKey], - }); - this.updateAction.emit({ - [questionKey]: [ - ...this.attachedFiles[questionKey].map((f) => { - if (f.file_id) { - const { name: _, ...payload } = f; - return payload; - } - return FilesMapper.toFilePayload(f as FileModel); - }), - ], - }); + default: + return null; } } - goBack(): void { - const previousStep = this.step() - 1; - if (previousStep > 0) { - this.router.navigate(['../', previousStep], { relativeTo: this.route }); - } else { - this.back.emit(); + private saveStepState() { + if (!this.stepForm.controls || !Object.keys(this.stepForm.controls).length) { + return; } - } - goNext(): void { - const nextStep = this.step() + 1; - if (nextStep <= this.pages().length) { - this.router.navigate(['../', nextStep], { relativeTo: this.route }); - } else { - this.next.emit(); + const changedFields = findChangedFields(this.stepForm.value, this.stepsData()); + if (Object.keys(changedFields).length > 0) { + this.actions.setUpdatedFields(changedFields); + this.updateAction.emit(this.stepForm.value); } + + this.stepForm.markAllAsTouched(); + this.actions.updateStepState(this.step(), this.stepForm.invalid, true); } - ngOnDestroy(): void { - this.updateStepState(); + private mapFilesToPayload(files: AttachedFile[]): FilePayloadJsonApi[] { + return files.map((f) => { + if (f.file_id) { + const { name: _, ...payload } = f; + return payload as FilePayloadJsonApi; + } + return FilesMapper.toFilePayload(f as FileModel); + }); } } diff --git a/src/app/features/registries/components/drafts/drafts.component.spec.ts b/src/app/features/registries/components/drafts/drafts.component.spec.ts index 98221ba90..7325770a2 100644 --- a/src/app/features/registries/components/drafts/drafts.component.spec.ts +++ b/src/app/features/registries/components/drafts/drafts.component.spec.ts @@ -1,76 +1,452 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { LoaderService } from '@osf/shared/services/loader.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; import { SubjectsSelectors } from '@osf/shared/stores/subjects'; -import { RegistriesSelectors } from '../../store'; +import { ClearState, RegistriesSelectors } from '../../store'; import { DraftsComponent } from './drafts.component'; -import { MOCK_DRAFT_REGISTRATION, MOCK_PAGES_SCHEMA } from '@testing/mocks/registries.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { + MOCK_DRAFT_REGISTRATION, + MOCK_PAGES_SCHEMA, + MOCK_REGISTRIES_PAGE_WITH_SECTIONS, + MOCK_STEPS_DATA, +} from '@testing/mocks/registries.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; 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('DraftsComponent', () => { - let component: DraftsComponent; - let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; +interface SetupOverrides { + routeParams?: Record; + firstChildParams?: Record | null; + routerUrl?: string; + routerEvents?: unknown; + selectorOverrides?: { selector: unknown; value: unknown }[]; +} - const MOCK_PAGES = MOCK_PAGES_SCHEMA; - const MOCK_DRAFT = MOCK_DRAFT_REGISTRATION; +function setup(overrides: SetupOverrides = {}) { + const routeBuilder = ActivatedRouteMockBuilder.create().withParams(overrides.routeParams ?? { id: 'reg-1' }); + const mockActivatedRoute = routeBuilder.build(); - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'reg-1' }).build(); - mockRouter = RouterMockBuilder.create().withUrl('/registries/drafts/reg-1/1').build(); + if (overrides.firstChildParams === null) { + (mockActivatedRoute as unknown as Record)['firstChild'] = null; + (mockActivatedRoute.snapshot as unknown as Record)['firstChild'] = null; + } else { + const childParams = overrides.firstChildParams ?? { id: 'reg-1', step: '1' }; + (mockActivatedRoute.snapshot as unknown as Record)['firstChild'] = { params: childParams }; + (mockActivatedRoute as unknown as Record)['firstChild'] = { snapshot: { params: childParams } }; + } + + const mockRouter = RouterMockBuilder.create() + .withUrl(overrides.routerUrl ?? '/registries/drafts/reg-1/1') + .build(); + if (overrides.routerEvents !== undefined) { + mockRouter.events = overrides.routerEvents as RouterMockType['events']; + } else { mockRouter.events = of(new NavigationEnd(1, '/', '/')); + } - await TestBed.configureTestingModule({ - imports: [DraftsComponent, OSFTestingModule, ...MockComponents(StepperComponent, SubHeaderComponent)], - providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(Router, mockRouter), - provideMockStore({ - signals: [ - { selector: RegistriesSelectors.getPagesSchema, value: MOCK_PAGES }, - { selector: RegistriesSelectors.getDraftRegistration, value: MOCK_DRAFT }, - { selector: RegistriesSelectors.getStepsState, value: {} }, - { selector: RegistriesSelectors.getStepsData, value: {} }, - { selector: ContributorsSelectors.getContributors, value: [] }, - { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, - ], - }), - ], - }).compileComponents(); + const defaultSignals: { selector: unknown; value: unknown }[] = [ + { selector: RegistriesSelectors.getPagesSchema, value: MOCK_PAGES_SCHEMA }, + { selector: RegistriesSelectors.getDraftRegistration, value: MOCK_DRAFT_REGISTRATION }, + { selector: RegistriesSelectors.getRegistrationLicense, value: { id: 'mit' } }, + { selector: RegistriesSelectors.getStepsState, value: {} }, + { selector: RegistriesSelectors.getStepsData, value: {} }, + { selector: ContributorsSelectors.getContributors, value: [{ id: 'c1' }] }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [{ id: 's1' }] }, + ]; + + const signals = overrides.selectorOverrides + ? defaultSignals.map((s) => { + const override = overrides.selectorOverrides!.find((o) => o.selector === s.selector); + return override ? { ...s, value: override.value } : s; + }) + : defaultSignals; + + TestBed.configureTestingModule({ + imports: [DraftsComponent, ...MockComponents(StepperComponent, SubHeaderComponent)], + providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(LoaderService, new LoaderServiceMock()), + provideMockStore({ signals }), + ], + }); + + const store = TestBed.inject(Store); + const fixture = TestBed.createComponent(DraftsComponent); + const component = fixture.componentInstance; + + return { + fixture, + component, + store, + mockRouter: TestBed.inject(Router) as unknown as RouterMockType, + mockActivatedRoute, + }; +} + +describe('DraftsComponent', () => { + let component: DraftsComponent; + let fixture: ComponentFixture; + let store: Store; + let mockRouter: RouterMockType; - fixture = TestBed.createComponent(DraftsComponent); - component = fixture.componentInstance; + beforeEach(() => { + const result = setup(); + fixture = result.fixture; + component = result.component; + store = result.store; + mockRouter = result.mockRouter; }); it('should create', () => { expect(component).toBeTruthy(); }); + it('should resolve registrationId from route firstChild', () => { + expect(component.registrationId).toBe('reg-1'); + }); + it('should compute isReviewPage from router url', () => { expect(component.isReviewPage).toBe(false); - const router = TestBed.inject(Router) as any; - router.url = '/registries/drafts/reg-1/review'; + (mockRouter as unknown as Record)['url'] = '/registries/drafts/reg-1/review'; expect(component.isReviewPage).toBe(true); }); it('should build steps from pages and defaults', () => { const steps = component.steps(); - expect(Array.isArray(steps)).toBe(true); expect(steps.length).toBe(3); + expect(steps[0].routeLink).toBe('metadata'); + expect(steps[1].label).toBe('Page 1'); + expect(steps[2].routeLink).toBe('review'); + }); + + it('should set currentStepIndex from route params', () => { + expect(component.currentStepIndex()).toBe(1); + }); + + it('should compute currentStep from steps and currentStepIndex', () => { expect(component.currentStep()).toBeDefined(); + expect(component.currentStep().label).toBe('Page 1'); + }); + + it('should compute isMetaDataInvalid as false when all fields present', () => { + expect(component.isMetaDataInvalid()).toBe(false); + }); + + it('should navigate and update currentStepIndex on stepChange', () => { + component.stepChange({ index: 0, label: 'Metadata', value: '' }); + + expect(component.currentStepIndex()).toBe(0); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/drafts/reg-1/', 'metadata']); + }); + + it('should dispatch clearState on destroy', () => { + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); + }); + + it('should compute isMetaDataInvalid as true when title is missing', () => { + const { component: c } = setup({ + selectorOverrides: [ + { selector: RegistriesSelectors.getDraftRegistration, value: { ...MOCK_DRAFT_REGISTRATION, title: '' } }, + ], + }); + + expect(c.isMetaDataInvalid()).toBe(true); + }); + + it('should compute isMetaDataInvalid as true when subjects are empty', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: SubjectsSelectors.getSelectedSubjects, value: [] }], + }); + + expect(c.isMetaDataInvalid()).toBe(true); + }); + + it('should set metadata step as invalid when license is missing', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationLicense, value: null }], + }); + + const steps = c.steps(); + expect(steps[0].invalid).toBe(true); + }); + + it('should dispatch getDraftRegistration when draftRegistration is null', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getDraftRegistration, value: null }], + }); + + expect(c).toBeTruthy(); + }); + + it('should dispatch getContributors when contributors list is empty', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: ContributorsSelectors.getContributors, value: [] }], + }); + + expect(c).toBeTruthy(); + }); + + it('should dispatch getSubjects when selectedSubjects list is empty', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: SubjectsSelectors.getSelectedSubjects, value: [] }], + }); + + expect(c).toBeTruthy(); + }); + + it('should dispatch all actions when all initial data is missing', () => { + const { component: c } = setup({ + selectorOverrides: [ + { selector: RegistriesSelectors.getDraftRegistration, value: null }, + { selector: ContributorsSelectors.getContributors, value: [] }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, + ], + }); + + expect(c).toBeTruthy(); + }); + + it('should hide loader after schema blocks are fetched', fakeAsync(() => { + fixture.detectChanges(); + tick(); + + const loaderService = TestBed.inject(LoaderService); + expect(loaderService.hide).toHaveBeenCalled(); + })); + + it('should not fetch schema blocks when draft has no registrationSchemaId', () => { + const { fixture: f } = setup({ + selectorOverrides: [ + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...MOCK_DRAFT_REGISTRATION, registrationSchemaId: '' }, + }, + ], + }); + + f.detectChanges(); + + const loaderService = TestBed.inject(LoaderService); + expect(loaderService.hide).not.toHaveBeenCalled(); + }); + + it('should set currentStepIndex to pages.length + 1 on review navigation', () => { + const { component: c } = setup({ + routerUrl: '/registries/drafts/reg-1/review', + firstChildParams: null, + }); + + expect(c.currentStepIndex()).toBe(MOCK_PAGES_SCHEMA.length + 1); + }); + + it('should reset currentStepIndex to 0 when no step and not review', () => { + const { component: c } = setup({ + routerUrl: '/registries/drafts/reg-1/metadata', + firstChildParams: { id: 'reg-1' }, + }); + + expect(c.currentStepIndex()).toBe(0); + }); + + it('should set currentStepIndex from step param on navigation', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '2' }, + }); + + expect(c.currentStepIndex()).toBe(2); + }); + + it('should sync currentStepIndex to review step when on review page', () => { + const { component: c } = setup({ + routerUrl: '/registries/drafts/reg-1/review', + firstChildParams: null, + }); + + expect(c.currentStepIndex()).toBe(MOCK_PAGES_SCHEMA.length + 1); + }); + + it('should include questions from sections when building steps', () => { + const pagesWithSections = [...MOCK_PAGES_SCHEMA, MOCK_REGISTRIES_PAGE_WITH_SECTIONS]; + + const { component: c } = setup({ + selectorOverrides: [ + { selector: RegistriesSelectors.getPagesSchema, value: pagesWithSections }, + { selector: RegistriesSelectors.getStepsData, value: { field1: 'v1', field3: 'v3' } }, + ], + }); + + const steps = c.steps(); + expect(steps.length).toBe(4); + expect(steps[2].label).toBe('Page 2'); + expect(steps[2].touched).toBe(true); + }); + + it('should not mark section step as touched when no data for section questions', () => { + const pagesWithSections = [...MOCK_PAGES_SCHEMA, MOCK_REGISTRIES_PAGE_WITH_SECTIONS]; + + const { component: c } = setup({ + selectorOverrides: [ + { selector: RegistriesSelectors.getPagesSchema, value: pagesWithSections }, + { selector: RegistriesSelectors.getStepsData, value: {} }, + ], + }); + + const steps = c.steps(); + expect(steps[2].touched).toBe(false); + }); + + it('should mark step as invalid when required field has empty array', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '2' }, + selectorOverrides: [ + { selector: RegistriesSelectors.getStepsData, value: { field1: [], field2: 'v2' } }, + { selector: RegistriesSelectors.getStepsState, value: { 1: { invalid: true, touched: true } } }, + ], + }); + + const steps = c.steps(); + expect(steps[1].invalid).toBe(true); + }); + + it('should not mark step as invalid when required field has non-empty array', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '2' }, + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: { field1: ['item'], field2: 'v2' } }], + }); + + const steps = c.steps(); + expect(steps[1].invalid).toBe(false); + }); + + it('should not mark step as invalid when required field has truthy value', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '2' }, + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: { field1: 'value', field2: '' } }], + }); + + const steps = c.steps(); + expect(steps[1].invalid).toBe(false); + }); + + it('should mark step as invalid when required field is falsy', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '2' }, + selectorOverrides: [ + { selector: RegistriesSelectors.getStepsData, value: { field1: '', field2: 'v2' } }, + { selector: RegistriesSelectors.getStepsState, value: { 1: { invalid: true, touched: true } } }, + ], + }); + + const steps = c.steps(); + expect(steps[1].invalid).toBe(true); + }); + + it('should detect hasStepData with array data', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: { field1: ['item1'] } }], + }); + + const steps = c.steps(); + expect(steps[1].touched).toBe(true); + }); + + it('should not detect hasStepData with empty array', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: { field1: [] } }], + }); + + const steps = c.steps(); + expect(steps[1].touched).toBe(false); + }); + + it('should validate previous steps when currentStepIndex > 0', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '1' }, + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: { field1: 'v1' } }], + }); + + expect(c.currentStepIndex()).toBe(1); + expect(c).toBeTruthy(); + }); + + it('should not validate steps when currentStepIndex is 0', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '0' }, + routerUrl: '/registries/drafts/reg-1/metadata', + }); + + expect(c.currentStepIndex()).toBe(0); + }); + + it('should validate metadata step as invalid when license is missing', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '1' }, + selectorOverrides: [ + { selector: RegistriesSelectors.getRegistrationLicense, value: null }, + { selector: RegistriesSelectors.getStepsData, value: { field1: 'v1' } }, + ], + }); + + expect(c.isMetaDataInvalid()).toBe(true); + }); + + it('should validate metadata step as invalid when description is missing', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '1' }, + selectorOverrides: [ + { selector: RegistriesSelectors.getDraftRegistration, value: { ...MOCK_DRAFT_REGISTRATION, description: '' } }, + { selector: RegistriesSelectors.getStepsData, value: { field1: 'v1' } }, + ], + }); + + expect(c.isMetaDataInvalid()).toBe(true); + }); + + it('should default registrationId to empty string when no firstChild', () => { + const { component: c } = setup({ + routerUrl: '/registries/drafts/', + firstChildParams: null, + }); + + expect(c.registrationId).toBe(''); + }); + + it('should default currentStepIndex to 0 when step param is absent', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1' }, + routerUrl: '/registries/drafts/reg-1/metadata', + }); + + expect(c.currentStepIndex()).toBe(0); + }); + + it('should mark step as touched when stepsData has matching keys', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: MOCK_STEPS_DATA }], + }); + + const steps = c.steps(); + expect(steps[1].touched).toBe(true); }); }); diff --git a/src/app/features/registries/components/drafts/drafts.component.ts b/src/app/features/registries/components/drafts/drafts.component.ts index a1d9fd448..f1579427a 100644 --- a/src/app/features/registries/components/drafts/drafts.component.ts +++ b/src/app/features/registries/components/drafts/drafts.component.ts @@ -2,7 +2,7 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { filter, tap } from 'rxjs'; +import { filter, switchMap, take } from 'rxjs'; import { ChangeDetectionStrategy, @@ -12,11 +12,10 @@ import { effect, inject, OnDestroy, - Signal, signal, untracked, } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; @@ -37,7 +36,6 @@ import { ClearState, FetchDraft, FetchSchemaBlocks, RegistriesSelectors, UpdateS templateUrl: './drafts.component.html', styleUrl: './drafts.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [TranslateService], }) export class DraftsComponent implements OnDestroy { private readonly router = inject(Router); @@ -48,13 +46,12 @@ export class DraftsComponent implements OnDestroy { readonly pages = select(RegistriesSelectors.getPagesSchema); readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); - stepsState = select(RegistriesSelectors.getStepsState); - readonly stepsData = select(RegistriesSelectors.getStepsData); - selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); - initialContributors = select(ContributorsSelectors.getContributors); - readonly contributors = select(ContributorsSelectors.getContributors); - readonly subjects = select(SubjectsSelectors.getSelectedSubjects); - readonly registrationLicense = select(RegistriesSelectors.getRegistrationLicense); + readonly stepsState = select(RegistriesSelectors.getStepsState); + + private readonly stepsData = select(RegistriesSelectors.getStepsData); + private readonly registrationLicense = select(RegistriesSelectors.getRegistrationLicense); + private readonly selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); + private readonly contributors = select(ContributorsSelectors.getContributors); private readonly actions = createDispatchMap({ getSchemaBlocks: FetchSchemaBlocks, @@ -69,146 +66,161 @@ export class DraftsComponent implements OnDestroy { return this.router.url.includes('/review'); } - isMetaDataInvalid = computed(() => { - return ( + isMetaDataInvalid = computed( + () => !this.draftRegistration()?.title || !this.draftRegistration()?.description || !this.registrationLicense() || !this.selectedSubjects()?.length - ); - }); - - defaultSteps: StepOption[] = []; - - isLoaded = false; + ); - steps: Signal = computed(() => { + steps = computed(() => { const stepState = this.stepsState(); const stepData = this.stepsData(); - this.defaultSteps = DEFAULT_STEPS.map((step) => ({ - ...step, - label: this.translateService.instant(step.label), - invalid: stepState?.[step.index]?.invalid || false, + + const metadataStep: StepOption = { + ...DEFAULT_STEPS[0], + label: this.translateService.instant(DEFAULT_STEPS[0].label), + invalid: this.isMetaDataInvalid(), + touched: true, + }; + + const customSteps: StepOption[] = this.pages().map((page, index) => ({ + index: index + 1, + label: page.title, + value: page.id, + routeLink: `${index + 1}`, + invalid: stepState?.[index + 1]?.invalid || false, + touched: stepState?.[index + 1]?.touched || this.hasStepData(page, stepData), })); - this.defaultSteps[0].invalid = this.isMetaDataInvalid(); - this.defaultSteps[0].touched = true; - const customSteps = this.pages().map((page, index) => { - const pageStep = this.pages()[index]; - const allQuestions = this.getAllQuestions(pageStep); - const wasTouched = - allQuestions?.some((question) => { - const questionData = stepData[question.responseKey!]; - return Array.isArray(questionData) ? questionData.length : questionData; - }) || false; - return { - index: index + 1, - label: page.title, - value: page.id, - routeLink: `${index + 1}`, - invalid: stepState?.[index + 1]?.invalid || false, - touched: stepState?.[index + 1]?.touched || wasTouched, - }; - }); - return [ - this.defaultSteps[0], - ...customSteps, - { ...this.defaultSteps[1], index: customSteps.length + 1, invalid: false }, - ]; + const reviewStep: StepOption = { + ...DEFAULT_STEPS[1], + label: this.translateService.instant(DEFAULT_STEPS[1].label), + index: customSteps.length + 1, + invalid: false, + }; + + return [metadataStep, ...customSteps, reviewStep]; }); + registrationId = this.route.snapshot.firstChild?.params['id'] || ''; + currentStepIndex = signal( this.route.snapshot.firstChild?.params['step'] ? +this.route.snapshot.firstChild?.params['step'] : 0 ); currentStep = computed(() => this.steps()[this.currentStepIndex()]); - registrationId = this.route.snapshot.firstChild?.params['id'] || ''; - constructor() { + this.loadInitialData(); + this.setupSchemaLoader(); + this.setupRouteWatcher(); + this.setupReviewStepSync(); + this.setupStepValidation(); + } + + ngOnDestroy(): void { + this.actions.clearState(); + } + + stepChange(step: StepOption): void { + this.currentStepIndex.set(step.index); + this.router.navigate([`/registries/drafts/${this.registrationId}/`, this.steps()[step.index].routeLink]); + } + + private loadInitialData() { + this.loaderService.show(); + + if (!this.draftRegistration()) { + this.actions.getDraftRegistration(this.registrationId); + } + + if (!this.contributors()?.length) { + this.actions.getContributors(this.registrationId, ResourceType.DraftRegistration); + } + + if (!this.selectedSubjects()?.length) { + this.actions.getSubjects(this.registrationId, ResourceType.DraftRegistration); + } + } + + private setupSchemaLoader() { + toObservable(this.draftRegistration) + .pipe( + filter((draft) => !!draft?.registrationSchemaId), + take(1), + switchMap((draft) => this.actions.getSchemaBlocks(draft!.registrationSchemaId)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.loaderService.hide()); + } + + private setupRouteWatcher() { this.router.events .pipe( - takeUntilDestroyed(this.destroyRef), - filter((event): event is NavigationEnd => event instanceof NavigationEnd) + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) ) .subscribe(() => { const step = this.route.firstChild?.snapshot.params['step']; if (step) { this.currentStepIndex.set(+step); } else if (this.isReviewPage) { - const reviewStepIndex = this.pages().length + 1; - this.currentStepIndex.set(reviewStepIndex); + this.currentStepIndex.set(this.pages().length + 1); } else { this.currentStepIndex.set(0); } }); + } - this.loaderService.show(); - if (!this.draftRegistration()) { - this.actions.getDraftRegistration(this.registrationId); - } - if (!this.contributors()?.length) { - this.actions.getContributors(this.registrationId, ResourceType.DraftRegistration); - } - if (!this.subjects()?.length) { - this.actions.getSubjects(this.registrationId, ResourceType.DraftRegistration); - } - effect(() => { - const registrationSchemaId = this.draftRegistration()?.registrationSchemaId; - if (registrationSchemaId && !this.isLoaded) { - this.actions - .getSchemaBlocks(registrationSchemaId || '') - .pipe( - tap(() => { - this.isLoaded = true; - this.loaderService.hide(); - }) - ) - .subscribe(); - } - }); - + private setupReviewStepSync() { effect(() => { const reviewStepIndex = this.pages().length + 1; if (this.isReviewPage) { this.currentStepIndex.set(reviewStepIndex); } }); + } + private setupStepValidation() { effect(() => { const stepState = untracked(() => this.stepsState()); - if (this.currentStepIndex() > 0) { + const currentIndex = this.currentStepIndex(); + + if (currentIndex > 0) { this.actions.updateStepState('0', this.isMetaDataInvalid(), stepState?.[0]?.touched || false); } - if (this.pages().length && this.currentStepIndex() > 0 && this.stepsData()) { - for (let i = 1; i < this.currentStepIndex(); i++) { - const pageStep = this.pages()[i - 1]; - const allQuestions = this.getAllQuestions(pageStep); - const isStepInvalid = - allQuestions?.some((question) => { - const questionData = this.stepsData()[question.responseKey!]; - return question.required && (Array.isArray(questionData) ? !questionData.length : !questionData); - }) || false; - this.actions.updateStepState(i.toString(), isStepInvalid, stepState?.[i]?.touched || false); + + if (this.pages().length && currentIndex > 0 && this.stepsData()) { + for (let i = 1; i < currentIndex; i++) { + const page = this.pages()[i - 1]; + const invalid = this.isPageInvalid(page, this.stepsData()); + this.actions.updateStepState(i.toString(), invalid, stepState?.[i]?.touched || false); } } }); } - stepChange(step: StepOption): void { - this.currentStepIndex.set(step.index); - const pageLink = this.steps()[step.index].routeLink; - this.router.navigate([`/registries/drafts/${this.registrationId}/`, pageLink]); + private getAllQuestions(page: PageSchema): Question[] { + return [...(page?.questions ?? []), ...(page?.sections?.flatMap((section) => section.questions ?? []) ?? [])]; } - private getAllQuestions(pageStep: PageSchema): Question[] { - return [ - ...(pageStep?.questions ?? []), - ...(pageStep?.sections?.flatMap((section) => section.questions ?? []) ?? []), - ]; + private hasStepData(page: PageSchema, stepData: Record): boolean { + return ( + this.getAllQuestions(page).some((question) => { + const data = stepData[question.responseKey!]; + return Array.isArray(data) ? data.length : data; + }) || false + ); } - ngOnDestroy(): void { - this.actions.clearState(); + private isPageInvalid(page: PageSchema, stepData: Record): boolean { + return ( + this.getAllQuestions(page).some((question) => { + const data = stepData[question.responseKey!]; + return question.required && (Array.isArray(data) ? !data.length : !data); + }) || false + ); } } diff --git a/src/app/features/registries/components/files-control/files-control.component.html b/src/app/features/registries/components/files-control/files-control.component.html index bf53c0a2f..8d3350ae2 100644 --- a/src/app/features/registries/components/files-control/files-control.component.html +++ b/src/app/features/registries/components/files-control/files-control.component.html @@ -13,7 +13,7 @@ severity="success" [icon]="'fas fa-plus'" [label]="'files.actions.createFolder' | translate" - (click)="createFolder()" + (onClick)="createFolder()" > @@ -24,7 +24,7 @@ severity="success" [icon]="'fas fa-upload'" [label]="'files.actions.uploadFile' | translate" - (click)="fileInput.click()" + (onClick)="fileInput.click()" > @@ -50,6 +50,8 @@ [viewOnly]="filesViewOnly()" [resourceId]="projectId()" [provider]="provider()" + [selectedFiles]="filesSelection" + (selectFile)="onFileTreeSelected($event)" (entryFileClicked)="selectFile($event)" (uploadFilesConfirmed)="uploadFiles($event)" (loadFiles)="onLoadFiles($event)" diff --git a/src/app/features/registries/components/files-control/files-control.component.spec.ts b/src/app/features/registries/components/files-control/files-control.component.spec.ts index 79257199d..e1a26b51e 100644 --- a/src/app/features/registries/components/files-control/files-control.component.spec.ts +++ b/src/app/features/registries/components/files-control/files-control.component.spec.ts @@ -1,13 +1,26 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { of, Subject } from 'rxjs'; +import { HttpEventType } from '@angular/common/http'; +import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { HelpScoutService } from '@core/services/help-scout.service'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { + CreateFolder, + GetFiles, + RegistriesSelectors, + SetFilesIsLoading, + SetRegistriesCurrentFolder, +} from '@osf/features/registries/store'; import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.component'; +import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; +import { FILE_SIZE_LIMIT } from '@osf/shared/constants/files-limits.const'; +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 { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { FilesService } from '@osf/shared/services/files.service'; @@ -15,57 +28,73 @@ import { ToastService } from '@osf/shared/services/toast.service'; import { FilesControlComponent } from './files-control.component'; -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 { HelpScoutServiceMockFactory } from '@testing/providers/help-scout.service.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { MockComponentWithSignal } from '@testing/providers/component-provider.mock'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; -describe('Component: File Control', () => { +describe('FilesControlComponent', () => { let component: FilesControlComponent; let fixture: ComponentFixture; - let helpScoutService: HelpScoutService; - let mockFilesService: jest.Mocked; - let mockDialogService: ReturnType; - let mockToastService: ReturnType; - let mockCustomConfirmationService: ReturnType; - const currentFolder = { - links: { newFolder: '/new-folder', upload: '/upload' }, - relationships: { filesLink: '/files-link' }, - } as any; - - beforeEach(async () => { - mockFilesService = { uploadFile: jest.fn(), getFileGuid: jest.fn() } as any; + let store: Store; + let mockFilesService: { uploadFile: jest.Mock; getFileGuid: jest.Mock }; + let mockDialogService: CustomDialogServiceMockType; + let currentFolderSignal: WritableSignal; + let toastService: ToastServiceMockType; + + const CURRENT_FOLDER = { + links: { newFolder: '/new-folder', upload: '/upload', filesLink: '/files-link' }, + } as FileFolderModel; + + beforeEach(() => { + mockFilesService = { uploadFile: jest.fn(), getFileGuid: jest.fn() }; mockDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - mockToastService = ToastServiceMockBuilder.create().build(); - mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); - helpScoutService = HelpScoutServiceMockFactory(); - - await TestBed.configureTestingModule({ - imports: [ - FilesControlComponent, - OSFTestingModule, - ...MockComponents(LoadingSpinnerComponent, FileUploadDialogComponent), - ], + currentFolderSignal = signal(CURRENT_FOLDER); + toastService = ToastServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [FilesControlComponent, ...MockComponents(LoadingSpinnerComponent, FileUploadDialogComponent)], providers: [ + provideOSFCore(), + MockProvider(ToastService, toastService), + MockProvider(CustomConfirmationService), MockProvider(FilesService, mockFilesService), MockProvider(CustomDialogService, mockDialogService), - MockProvider(ToastService, mockToastService), - MockProvider(CustomConfirmationService, mockCustomConfirmationService), - { provide: HelpScoutService, useValue: helpScoutService }, provideMockStore({ signals: [ { selector: RegistriesSelectors.getFiles, value: [] }, { selector: RegistriesSelectors.getFilesTotalCount, value: 0 }, { selector: RegistriesSelectors.isFilesLoading, value: false }, - { selector: RegistriesSelectors.getCurrentFolder, value: currentFolder }, + { selector: RegistriesSelectors.getCurrentFolder, value: currentFolderSignal }, ], }), ], - }).compileComponents(); + }).overrideComponent(FilesControlComponent, { + remove: { imports: [FilesTreeComponent] }, + add: { + imports: [ + MockComponentWithSignal('osf-files-tree', [ + 'files', + 'selectionMode', + 'totalCount', + 'storage', + 'currentFolder', + 'isLoading', + 'scrollHeight', + 'viewOnly', + 'resourceId', + 'provider', + 'selectedFiles', + ]), + ], + }, + }); - helpScoutService = TestBed.inject(HelpScoutService); + store = TestBed.inject(Store); fixture = TestBed.createComponent(FilesControlComponent); component = fixture.componentInstance; fixture.componentRef.setInput('attachedFiles', []); @@ -76,47 +105,148 @@ describe('Component: File Control', () => { fixture.detectChanges(); }); - it('should have a default value', () => { - expect(component.fileIsUploading()).toBeFalsy(); + it('should create with default signal values', () => { + expect(component).toBeTruthy(); + expect(component.fileIsUploading()).toBe(false); + expect(component.progress()).toBe(0); + expect(component.fileName()).toBe(''); }); - it('should called the helpScoutService', () => { - expect(helpScoutService.setResourceType).toHaveBeenCalledWith('files'); + it('should do nothing when no file is selected', () => { + const event = { target: { files: [] } } as unknown as Event; + const uploadSpy = jest.spyOn(component, 'uploadFiles'); + + component.onFileSelected(event); + + expect(uploadSpy).not.toHaveBeenCalled(); + }); + + it('should show warning when file exceeds size limit', () => { + const oversizedFile = new File([''], 'big.bin'); + Object.defineProperty(oversizedFile, 'size', { value: FILE_SIZE_LIMIT }); + const event = { target: { files: [oversizedFile] } } as unknown as Event; + + component.onFileSelected(event); + + expect(toastService.showWarn).toHaveBeenCalledWith('shared.files.limitText'); + }); + + it('should upload valid file', () => { + const file = new File(['data'], 'test.txt'); + const event = { target: { files: [file] } } as unknown as Event; + const uploadSpy = jest.spyOn(component, 'uploadFiles').mockImplementation(); + + component.onFileSelected(event); + + expect(uploadSpy).toHaveBeenCalledWith(file); }); - it('should open create folder dialog and trigger files update', () => { + it('should open dialog and dispatch createFolder on confirm', () => { const onClose$ = new Subject(); - (mockDialogService.open as any).mockReturnValue({ onClose: onClose$ }); - const updateSpy = jest.spyOn(component, 'updateFilesList').mockReturnValue(of(void 0)); + mockDialogService.open.mockReturnValue({ onClose: onClose$ } as any); + (store.dispatch as jest.Mock).mockClear(); component.createFolder(); expect(mockDialogService.open).toHaveBeenCalled(); onClose$.next('New Folder'); - expect(updateSpy).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new CreateFolder('/new-folder', 'New Folder')); }); - it('should upload files, update progress and select uploaded file', () => { - const file = new File(['data'], 'test.txt', { type: 'text/plain' }); - const progress = { type: 1, loaded: 50, total: 100 } as any; - const response = { type: 4, body: { data: { id: 'files/abc' } } } as any; + it('should upload file, track progress, and select uploaded file', () => { + const file = new File(['data'], 'test.txt'); + const progress = { type: HttpEventType.UploadProgress, loaded: 50, total: 100 }; + const response = { type: HttpEventType.Response, body: { data: { id: 'files/abc' } } }; - (mockFilesService.uploadFile as any).mockReturnValue(of(progress, response)); - (mockFilesService.getFileGuid as any).mockReturnValue(of({ id: 'abc' })); + mockFilesService.uploadFile.mockReturnValue(of(progress, response)); + mockFilesService.getFileGuid.mockReturnValue(of({ id: 'abc' } as FileModel)); const selectSpy = jest.spyOn(component, 'selectFile'); component.uploadFiles(file); + expect(mockFilesService.uploadFile).toHaveBeenCalledWith(file, '/upload'); - expect(selectSpy).toHaveBeenCalledWith({ id: 'abc' } as any); + expect(component.progress()).toBe(50); + expect(selectSpy).toHaveBeenCalledWith({ id: 'abc' } as FileModel); + }); + + it('should not upload when no upload link', () => { + currentFolderSignal.set({ links: {} } as FileFolderModel); + + const file = new File(['data'], 'test.txt'); + component.uploadFiles(file); + + expect(mockFilesService.uploadFile).not.toHaveBeenCalled(); }); - it('should emit attachFile when selectFile and not view-only', (done) => { - const file = { id: 'file-1' } as any; + it('should handle File array input', () => { + const file = new File(['data'], 'test.txt'); + mockFilesService.uploadFile.mockReturnValue(of({ type: HttpEventType.Sent })); + + component.uploadFiles([file]); + + expect(mockFilesService.uploadFile).toHaveBeenCalledWith(file, '/upload'); + }); + + it('should emit attachFile when not view-only', (done) => { + const file = { id: 'file-1' } as FileModel; component.attachFile.subscribe((f) => { expect(f).toEqual(file); done(); }); component.selectFile(file); }); + + it('should not emit attachFile when filesViewOnly is true', () => { + fixture.componentRef.setInput('filesViewOnly', true); + fixture.detectChanges(); + + const emitSpy = jest.spyOn(component.attachFile, 'emit'); + component.selectFile({ id: 'file-1' } as FileModel); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should dispatch getFiles on onLoadFiles', () => { + (store.dispatch as jest.Mock).mockClear(); + + component.onLoadFiles({ link: '/files', page: 2 }); + + expect(store.dispatch).toHaveBeenCalledWith(new GetFiles('/files', 2)); + }); + + it('should dispatch setCurrentFolder', () => { + const folder = { id: 'folder-1' } as FileFolderModel; + (store.dispatch as jest.Mock).mockClear(); + + component.setCurrentFolder(folder); + + expect(store.dispatch).toHaveBeenCalledWith(new SetRegistriesCurrentFolder(folder)); + }); + + it('should add file to filesSelection and deduplicate', () => { + const file = { id: 'file-1' } as FileModel; + + component.onFileTreeSelected(file); + component.onFileTreeSelected(file); + + expect(component.filesSelection).toEqual([file]); + }); + + it('should not open dialog when no newFolder link', () => { + currentFolderSignal.set({ links: {} } as FileFolderModel); + + component.createFolder(); + + expect(mockDialogService.open).not.toHaveBeenCalled(); + }); + + it('should not dispatch getFiles when currentFolder has no filesLink', () => { + (store.dispatch as jest.Mock).mockClear(); + currentFolderSignal.set({ links: {} } as FileFolderModel); + fixture.detectChanges(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetFilesIsLoading)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetFiles)); + }); }); diff --git a/src/app/features/registries/components/files-control/files-control.component.ts b/src/app/features/registries/components/files-control/files-control.component.ts index ba1b578d8..423a65d45 100644 --- a/src/app/features/registries/components/files-control/files-control.component.ts +++ b/src/app/features/registries/components/files-control/files-control.component.ts @@ -5,24 +5,12 @@ import { TranslatePipe } from '@ngx-translate/core'; import { TreeDragDropService } from 'primeng/api'; import { Button } from 'primeng/button'; -import { EMPTY, filter, finalize, Observable, shareReplay, take } from 'rxjs'; +import { filter, finalize, switchMap, take } from 'rxjs'; import { HttpEventType } from '@angular/common/http'; -import { - ChangeDetectionStrategy, - Component, - DestroyRef, - effect, - inject, - input, - OnDestroy, - output, - signal, -} from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; - -import { HelpScoutService } from '@core/services/help-scout.service'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, output, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; + import { CreateFolderDialogComponent } from '@osf/features/files/components'; import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.component'; import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; @@ -47,12 +35,10 @@ import { @Component({ selector: 'osf-files-control', imports: [ - FilesTreeComponent, Button, + FilesTreeComponent, LoadingSpinnerComponent, FileUploadDialogComponent, - FormsModule, - ReactiveFormsModule, TranslatePipe, ClearFileDirective, ], @@ -61,19 +47,18 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, providers: [TreeDragDropService], }) -export class FilesControlComponent implements OnDestroy { +export class FilesControlComponent { attachedFiles = input.required[]>(); - attachFile = output(); filesLink = input.required(); projectId = input.required(); provider = input.required(); filesViewOnly = input(false); + attachFile = output(); private readonly filesService = inject(FilesService); private readonly customDialogService = inject(CustomDialogService); private readonly destroyRef = inject(DestroyRef); private readonly toastService = inject(ToastService); - private readonly helpScoutService = inject(HelpScoutService); readonly files = select(RegistriesSelectors.getFiles); readonly filesTotalCount = select(RegistriesSelectors.getFilesTotalCount); @@ -85,6 +70,7 @@ export class FilesControlComponent implements OnDestroy { readonly dataLoaded = signal(false); fileIsUploading = signal(false); + filesSelection: FileModel[] = []; private readonly actions = createDispatchMap({ createFolder: CreateFolder, @@ -95,44 +81,26 @@ export class FilesControlComponent implements OnDestroy { }); constructor() { - this.helpScoutService.setResourceType('files'); - effect(() => { - const filesLink = this.filesLink(); - if (filesLink) { - this.actions - .getRootFolders(filesLink) - .pipe(shareReplay(), takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.dataLoaded.set(true); - }); - } - }); - - effect(() => { - const currentFolder = this.currentFolder(); - if (currentFolder) { - this.updateFilesList().subscribe(); - } - }); + this.setupRootFoldersLoader(); + this.setupCurrentFolderWatcher(); } onFileSelected(event: Event): void { const input = event.target as HTMLInputElement; const file = input.files?.[0]; - if (file && file.size >= FILE_SIZE_LIMIT) { + if (!file) return; + + if (file.size >= FILE_SIZE_LIMIT) { this.toastService.showWarn('shared.files.limitText'); return; } - if (!file) return; this.uploadFiles(file); } createFolder(): void { - const currentFolder = this.currentFolder(); - const newFolderLink = currentFolder?.links.newFolder; - + const newFolderLink = this.currentFolder()?.links.newFolder; if (!newFolderLink) return; this.customDialogService @@ -140,35 +108,18 @@ export class FilesControlComponent implements OnDestroy { header: 'files.dialogs.createFolder.title', width: '448px', }) - .onClose.pipe(filter((folderName: string) => !!folderName)) - .subscribe((folderName) => { - this.actions - .createFolder(newFolderLink, folderName) - .pipe( - take(1), - finalize(() => { - this.updateFilesList().subscribe(() => this.fileIsUploading.set(false)); - }) - ) - .subscribe(); - }); - } - - updateFilesList(): Observable { - const currentFolder = this.currentFolder(); - if (currentFolder?.links.filesLink) { - this.actions.setFilesIsLoading(true); - return this.actions.getFiles(currentFolder?.links.filesLink, 1).pipe(take(1)); - } - - return EMPTY; + .onClose.pipe( + filter((folderName: string) => !!folderName), + switchMap((folderName) => this.actions.createFolder(newFolderLink, folderName)), + finalize(() => this.fileIsUploading.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.refreshFilesList()); } uploadFiles(files: File | File[]): void { - const fileArray = Array.isArray(files) ? files : [files]; - const file = fileArray[0]; - const currentFolder = this.currentFolder(); - const uploadLink = currentFolder?.links.upload; + const file = Array.isArray(files) ? files[0] : files; + const uploadLink = this.currentFolder()?.links.upload; if (!uploadLink) return; this.fileName.set(file.name); @@ -181,7 +132,7 @@ export class FilesControlComponent implements OnDestroy { finalize(() => { this.fileIsUploading.set(false); this.fileName.set(''); - this.updateFilesList(); + this.refreshFilesList(); }) ) .subscribe((event) => { @@ -189,17 +140,14 @@ export class FilesControlComponent implements OnDestroy { this.progress.set(Math.round((event.loaded / event.total) * 100)); } - if (event.type === HttpEventType.Response) { - if (event.body) { - const fileId = event?.body?.data?.id?.split('/').pop(); - if (fileId) { - this.filesService - .getFileGuid(fileId) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((file) => { - this.selectFile(file); - }); - } + if (event.type === HttpEventType.Response && event.body) { + const fileId = event.body.data?.id?.split('/').pop(); + + if (fileId) { + this.filesService + .getFileGuid(fileId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((uploadedFile) => this.selectFile(uploadedFile)); } } }); @@ -210,6 +158,11 @@ export class FilesControlComponent implements OnDestroy { this.attachFile.emit(file); } + onFileTreeSelected(file: FileModel): void { + this.filesSelection.push(file); + this.filesSelection = [...new Set(this.filesSelection)]; + } + onLoadFiles(event: { link: string; page: number }) { this.actions.getFiles(event.link, event.page); } @@ -218,7 +171,31 @@ export class FilesControlComponent implements OnDestroy { this.actions.setCurrentFolder(folder); } - ngOnDestroy(): void { - this.helpScoutService.unsetResourceType(); + private setupRootFoldersLoader() { + toObservable(this.filesLink) + .pipe( + filter((link) => !!link), + take(1), + switchMap((link) => this.actions.getRootFolders(link)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.dataLoaded.set(true)); + } + + private setupCurrentFolderWatcher() { + toObservable(this.currentFolder) + .pipe( + filter((folder) => !!folder), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.refreshFilesList()); + } + + private refreshFilesList(): void { + const filesLink = this.currentFolder()?.links.filesLink; + if (!filesLink) return; + + this.actions.setFilesIsLoading(true); + this.actions.getFiles(filesLink, 1); } } diff --git a/src/app/features/registries/components/justification-review/justification-review.component.html b/src/app/features/registries/components/justification-review/justification-review.component.html index a282a0fcc..8cf6e62e8 100644 --- a/src/app/features/registries/components/justification-review/justification-review.component.html +++ b/src/app/features/registries/components/justification-review/justification-review.component.html @@ -60,7 +60,7 @@

{{ section.title }}

}
- @if (inProgress) { + @if (inProgress()) { {{ section.title }} (onClick)="submit()" [loading]="isSchemaResponseLoading()" > - } @else if (isUnapproved) { + } @else if (isUnapproved()) { { let component: JustificationReviewComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; - let mockCustomDialogService: ReturnType; - let mockCustomConfirmationService: ReturnType; - let mockToastService: ReturnType; - - const MOCK_SCHEMA_RESPONSE = { - id: 'rev-1', + let store: Store; + let mockRouter: RouterMockType; + let mockCustomDialogService: CustomDialogServiceMockType; + let customConfirmationService: CustomConfirmationServiceMockType; + let toastService: ToastServiceMockType; + + const MOCK_SCHEMA_RESPONSE: Partial = { registrationId: 'reg-1', - reviewsState: RevisionReviewStates.RevisionInProgress, updatedResponseKeys: ['field1'], - } as any; + }; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1' }).build(); + beforeEach(() => { + const mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1' }).build(); mockRouter = RouterMockBuilder.create().withUrl('/x').build(); mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); - mockToastService = ToastServiceMockBuilder.create().build(); + toastService = ToastServiceMock.simple(); + customConfirmationService = CustomConfirmationServiceMock.simple(); - await TestBed.configureTestingModule({ - imports: [JustificationReviewComponent, OSFTestingModule, MockComponent(RegistrationBlocksDataComponent)], + TestBed.configureTestingModule({ + imports: [JustificationReviewComponent, MockComponent(RegistrationBlocksDataComponent)], providers: [ + provideOSFCore(), MockProvider(ActivatedRoute, mockActivatedRoute), MockProvider(Router, mockRouter), + MockProvider(ToastService, toastService), + MockProvider(CustomConfirmationService, customConfirmationService), MockProvider(CustomDialogService, mockCustomDialogService), - MockProvider(CustomConfirmationService, mockCustomConfirmationService), - MockProvider(ToastService, mockToastService), provideMockStore({ signals: [ { selector: RegistriesSelectors.getPagesSchema, value: MOCK_PAGES_SCHEMA }, @@ -65,8 +77,9 @@ describe('JustificationReviewComponent', () => { ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(JustificationReviewComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -85,55 +98,46 @@ describe('JustificationReviewComponent', () => { expect(mockRouter.navigate).toHaveBeenCalled(); }); - it('should submit revision for review', () => { - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + it('should dispatch handleSchemaResponse on submit', () => { + (store.dispatch as jest.Mock).mockClear(); component.submit(); - expect(mockActions.handleSchemaResponse).toHaveBeenCalledWith('rev-1', SchemaActionTrigger.Submit); - expect(mockToastService.showSuccess).toHaveBeenCalledWith('registries.justification.successSubmit'); + expect(store.dispatch).toHaveBeenCalledWith(new HandleSchemaResponse('rev-1', SchemaActionTrigger.Submit)); + expect(toastService.showSuccess).toHaveBeenCalledWith('registries.justification.successSubmit'); }); - it('should accept changes', () => { - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + it('should dispatch handleSchemaResponse on acceptChanges', () => { + (store.dispatch as jest.Mock).mockClear(); component.acceptChanges(); - expect(mockActions.handleSchemaResponse).toHaveBeenCalledWith('rev-1', SchemaActionTrigger.Approve); - expect(mockToastService.showSuccess).toHaveBeenCalledWith('registries.justification.successAccept'); + expect(store.dispatch).toHaveBeenCalledWith(new HandleSchemaResponse('rev-1', SchemaActionTrigger.Approve)); + expect(toastService.showSuccess).toHaveBeenCalledWith('registries.justification.successAccept'); expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/reg-1/overview'); }); - it('should continue editing and show decision recorded toast when confirmed', () => { - jest.spyOn(mockCustomDialogService, 'open').mockReturnValue({ onClose: of(true) } as any); + it('should show decision recorded toast when continueEditing confirmed', () => { + mockCustomDialogService.open.mockReturnValue({ onClose: of(true) } as any); component.continueEditing(); expect(mockCustomDialogService.open).toHaveBeenCalled(); - expect(mockToastService.showSuccess).toHaveBeenCalledWith('registries.justification.decisionRecorded'); + expect(toastService.showSuccess).toHaveBeenCalledWith('registries.justification.decisionRecorded'); }); - it('should delete draft update after confirmation', () => { - const mockActions = { - deleteSchemaResponse: jest.fn().mockReturnValue(of({})), - clearState: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + it('should dispatch deleteSchemaResponse and clearState after confirmation', () => { + (store.dispatch as jest.Mock).mockClear(); component.deleteDraftUpdate(); - expect(mockCustomConfirmationService.confirmDelete).toHaveBeenCalled(); - const call = (mockCustomConfirmationService.confirmDelete as any).mock.calls[0][0]; + + expect(customConfirmationService.confirmDelete).toHaveBeenCalled(); + const call = customConfirmationService.confirmDelete.mock.calls[0][0]; call.onConfirm(); - expect(mockActions.deleteSchemaResponse).toHaveBeenCalledWith('rev-1'); - expect(mockToastService.showSuccess).toHaveBeenCalledWith('registries.justification.successDeleteDraft'); - expect(mockActions.clearState).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteSchemaResponse('rev-1')); + expect(toastService.showSuccess).toHaveBeenCalledWith('registries.justification.successDeleteDraft'); + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/reg-1/overview'); }); }); diff --git a/src/app/features/registries/components/justification-review/justification-review.component.ts b/src/app/features/registries/components/justification-review/justification-review.component.ts index 26cb214f7..050fc1fa1 100644 --- a/src/app/features/registries/components/justification-review/justification-review.component.ts +++ b/src/app/features/registries/components/justification-review/justification-review.component.ts @@ -6,12 +6,14 @@ import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { filter } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.component'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; -import { FieldType } from '@osf/shared/enums/field-type.enum'; import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; @@ -34,6 +36,7 @@ export class JustificationReviewComponent { private readonly customConfirmationService = inject(CustomConfirmationService); private readonly customDialogService = inject(CustomDialogService); private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); readonly pages = select(RegistriesSelectors.getPagesSchema); readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); @@ -42,10 +45,8 @@ export class JustificationReviewComponent { readonly isSchemaResponseLoading = select(RegistriesSelectors.getSchemaResponseLoading); readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - readonly FieldType = FieldType; - readonly RevisionReviewStates = RevisionReviewStates; - actions = createDispatchMap({ + private readonly actions = createDispatchMap({ deleteSchemaResponse: DeleteSchemaResponse, handleSchemaResponse: HandleSchemaResponse, clearState: ClearState, @@ -53,50 +54,27 @@ export class JustificationReviewComponent { private readonly revisionId = this.route.snapshot.params['id']; - get isUnapproved() { - return this.schemaResponse()?.reviewsState === RevisionReviewStates.Unapproved; - } - - get inProgress() { - return this.schemaResponse()?.reviewsState === RevisionReviewStates.RevisionInProgress; - } + readonly isUnapproved = computed(() => this.schemaResponse()?.reviewsState === RevisionReviewStates.Unapproved); + readonly inProgress = computed(() => this.schemaResponse()?.reviewsState === RevisionReviewStates.RevisionInProgress); changes = computed(() => { - let questions: Record = {}; - this.pages().forEach((page) => { - if (page.sections?.length) { - questions = { - ...questions, - ...Object.fromEntries( - page.sections.flatMap( - (section) => section.questions?.map((q) => [q.responseKey, q.displayText || '']) || [] - ) - ), - }; - } else { - questions = { - ...questions, - ...Object.fromEntries(page.questions?.map((q) => [q.responseKey, q.displayText]) || []), - }; - } - }); - const updatedFields = this.updatedFields(); + const questions = this.buildQuestionMap(); const updatedResponseKeys = this.schemaResponse()?.updatedResponseKeys || []; - const uniqueKeys = new Set([...updatedResponseKeys, ...Object.keys(updatedFields)]); + const uniqueKeys = new Set([...updatedResponseKeys, ...Object.keys(this.updatedFields())]); return Array.from(uniqueKeys).map((key) => questions[key]); }); submit(): void { - this.actions.handleSchemaResponse(this.revisionId, SchemaActionTrigger.Submit).subscribe({ - next: () => { + this.actions + .handleSchemaResponse(this.revisionId, SchemaActionTrigger.Submit) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { this.toastService.showSuccess('registries.justification.successSubmit'); - }, - }); + }); } goBack(): void { - const previousStep = this.pages().length; - this.router.navigate(['../', previousStep], { relativeTo: this.route }); + this.router.navigate(['../', this.pages().length], { relativeTo: this.route }); } deleteDraftUpdate() { @@ -105,24 +83,26 @@ export class JustificationReviewComponent { messageKey: 'registries.justification.confirmDeleteUpdate.message', onConfirm: () => { const registrationId = this.schemaResponse()?.registrationId || ''; - this.actions.deleteSchemaResponse(this.revisionId).subscribe({ - next: () => { + this.actions + .deleteSchemaResponse(this.revisionId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { this.toastService.showSuccess('registries.justification.successDeleteDraft'); this.actions.clearState(); this.router.navigateByUrl(`/${registrationId}/overview`); - }, - }); + }); }, }); } acceptChanges() { - this.actions.handleSchemaResponse(this.revisionId, SchemaActionTrigger.Approve).subscribe({ - next: () => { + this.actions + .handleSchemaResponse(this.revisionId, SchemaActionTrigger.Approve) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { this.toastService.showSuccess('registries.justification.successAccept'); this.router.navigateByUrl(`/${this.schemaResponse()?.registrationId}/overview`); - }, - }); + }); } continueEditing() { @@ -130,14 +110,23 @@ export class JustificationReviewComponent { .open(ConfirmContinueEditingDialogComponent, { header: 'registries.justification.confirmContinueEditing.header', width: '552px', - data: { - revisionId: this.revisionId, - }, + data: { revisionId: this.revisionId }, }) - .onClose.subscribe((result) => { - if (result) { - this.toastService.showSuccess('registries.justification.decisionRecorded'); - } - }); + .onClose.pipe( + filter((result) => !!result), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.toastService.showSuccess('registries.justification.decisionRecorded')); + } + + private buildQuestionMap(): Record { + return Object.fromEntries( + this.pages().flatMap((page) => { + const questions = page.sections?.length + ? page.sections.flatMap((section) => section.questions || []) + : page.questions || []; + return questions.map((q) => [q.responseKey, q.displayText || '']); + }) + ); } } diff --git a/src/app/features/registries/components/justification-step/justification-step.component.html b/src/app/features/registries/components/justification-step/justification-step.component.html index e1265453e..7b3be3e89 100644 --- a/src/app/features/registries/components/justification-step/justification-step.component.html +++ b/src/app/features/registries/components/justification-step/justification-step.component.html @@ -12,7 +12,7 @@

{{ 'registries.justification.title' | translate }}

pTextarea formControlName="justification" > - @if (isJustificationValid) { + @if (showJustificationError) { {{ INPUT_VALIDATION_MESSAGES.required | translate }} @@ -25,7 +25,7 @@

{{ 'registries.justification.title' | translate }}

diff --git a/src/app/features/registries/components/justification-step/justification-step.component.spec.ts b/src/app/features/registries/components/justification-step/justification-step.component.spec.ts index c3b98e705..5e7c73b47 100644 --- a/src/app/features/registries/components/justification-step/justification-step.component.spec.ts +++ b/src/app/features/registries/components/justification-step/justification-step.component.spec.ts @@ -1,57 +1,67 @@ -import { MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { of } from 'rxjs'; +import { MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { + ClearState, + DeleteSchemaResponse, + RegistriesSelectors, + UpdateSchemaResponse, + UpdateStepState, +} from '@osf/features/registries/store'; +import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { JustificationStepComponent } from './justification-step.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('JustificationStepComponent', () => { let component: JustificationStepComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: jest.Mocked; - let mockCustomConfirmationService: ReturnType; - let mockToastService: ReturnType; + let store: Store; + let mockRouter: RouterMockType; + let toastService: ToastServiceMockType; + let customConfirmationService: CustomConfirmationServiceMockType; - const MOCK_SCHEMA_RESPONSE = { + const MOCK_SCHEMA_RESPONSE: Partial = { registrationId: 'reg-1', revisionJustification: 'reason', - } as any; + }; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1' }).build(); - mockRouter = { navigate: jest.fn(), navigateByUrl: jest.fn(), url: '/x' } as any; - mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); - mockToastService = ToastServiceMockBuilder.create().build(); + beforeEach(() => { + const mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1' }).build(); + mockRouter = RouterMockBuilder.create().withUrl('/x').build(); + toastService = ToastServiceMock.simple(); + customConfirmationService = CustomConfirmationServiceMock.simple(); - await TestBed.configureTestingModule({ - imports: [JustificationStepComponent, OSFTestingModule], + TestBed.configureTestingModule({ + imports: [JustificationStepComponent], providers: [ + provideOSFCore(), + MockProvider(ToastService, toastService), MockProvider(ActivatedRoute, mockActivatedRoute), MockProvider(Router, mockRouter), - MockProvider(CustomConfirmationService, mockCustomConfirmationService as any), - MockProvider(ToastService, mockToastService), + MockProvider(CustomConfirmationService, customConfirmationService), provideMockStore({ - signals: [ - { selector: RegistriesSelectors.getSchemaResponse, value: MOCK_SCHEMA_RESPONSE }, - { selector: RegistriesSelectors.getStepsState, value: {} }, - ], + signals: [{ selector: RegistriesSelectors.getSchemaResponse, value: MOCK_SCHEMA_RESPONSE }], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(JustificationStepComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -66,16 +76,12 @@ describe('JustificationStepComponent', () => { }); it('should submit justification and navigate to first step', () => { - const mockActions = { - updateRevision: jest.fn().mockReturnValue(of({})), - updateStepState: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); - component.justificationForm.patchValue({ justification: 'new reason' }); + (store.dispatch as jest.Mock).mockClear(); + component.submit(); - expect(mockActions.updateRevision).toHaveBeenCalledWith('rev-1', 'new reason'); + expect(store.dispatch).toHaveBeenCalledWith(new UpdateSchemaResponse('rev-1', 'new reason')); expect(mockRouter.navigate).toHaveBeenCalledWith(['../1'], { relativeTo: expect.any(Object), onSameUrlNavigation: 'reload', @@ -83,21 +89,36 @@ describe('JustificationStepComponent', () => { }); it('should delete draft update after confirmation', () => { - const mockActions = { - deleteSchemaResponse: jest.fn().mockReturnValue(of({})), - clearState: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + (store.dispatch as jest.Mock).mockClear(); component.deleteDraftUpdate(); - expect(mockCustomConfirmationService.confirmDelete).toHaveBeenCalled(); - const call = (mockCustomConfirmationService.confirmDelete as any).mock.calls[0][0]; + expect(customConfirmationService.confirmDelete).toHaveBeenCalled(); + const call = customConfirmationService.confirmDelete.mock.calls[0][0]; call.onConfirm(); - expect(mockActions.deleteSchemaResponse).toHaveBeenCalledWith('rev-1'); - expect(mockToastService.showSuccess).toHaveBeenCalledWith('registries.justification.successDeleteDraft'); - expect(mockActions.clearState).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteSchemaResponse('rev-1')); + expect(toastService.showSuccess).toHaveBeenCalledWith('registries.justification.successDeleteDraft'); + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/reg-1/overview'); }); + + it('should dispatch updateStepState and updateRevision on destroy when form changed', () => { + component.justificationForm.patchValue({ justification: 'changed reason' }); + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdateStepState('0', false, true)); + expect(store.dispatch).toHaveBeenCalledWith(new UpdateSchemaResponse('rev-1', 'changed reason')); + }); + + it('should not dispatch updateRevision on destroy when form is unchanged', () => { + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdateStepState('0', false, true)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdateSchemaResponse)); + }); }); diff --git a/src/app/features/registries/components/justification-step/justification-step.component.ts b/src/app/features/registries/components/justification-step/justification-step.component.ts index eee067b61..0ba540a0f 100644 --- a/src/app/features/registries/components/justification-step/justification-step.component.ts +++ b/src/app/features/registries/components/justification-step/justification-step.component.ts @@ -6,9 +6,10 @@ import { Button } from 'primeng/button'; import { Message } from 'primeng/message'; import { Textarea } from 'primeng/textarea'; -import { tap } from 'rxjs'; +import { filter, take } from 'rxjs'; -import { ChangeDetectionStrategy, Component, effect, inject, OnDestroy } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnDestroy, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; @@ -40,13 +41,13 @@ export class JustificationStepComponent implements OnDestroy { private readonly router = inject(Router); private readonly customConfirmationService = inject(CustomConfirmationService); private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); - readonly stepsState = select(RegistriesSelectors.getStepsState); readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - actions = createDispatchMap({ + private readonly actions = createDispatchMap({ updateStepState: UpdateStepState, updateRevision: UpdateSchemaResponse, deleteSchemaResponse: DeleteSchemaResponse, @@ -54,40 +55,51 @@ export class JustificationStepComponent implements OnDestroy { }); private readonly revisionId = this.route.snapshot.params['id']; + private readonly isDraftDeleted = signal(false); - justificationForm = this.fb.group({ + readonly justificationForm = this.fb.group({ justification: ['', [Validators.maxLength(InputLimits.description.maxLength), CustomValidators.requiredTrimmed()]], }); - get isJustificationValid(): boolean { + get showJustificationError(): boolean { const control = this.justificationForm.controls['justification']; return control.errors?.['required'] && (control.touched || control.dirty); } - isDraftDeleted = false; - constructor() { - effect(() => { - const revisionJustification = this.schemaResponse()?.revisionJustification; - if (revisionJustification) { - this.justificationForm.patchValue({ justification: revisionJustification }); - } - }); + this.setupInitialJustification(); + } + + ngOnDestroy(): void { + if (this.isDraftDeleted()) { + return; + } + + this.actions.updateStepState('0', this.justificationForm.invalid, true); + + const changes = findChangedFields( + { justification: this.justificationForm.value.justification! }, + { justification: this.schemaResponse()?.revisionJustification } + ); + + if (Object.keys(changes).length > 0) { + this.actions.updateRevision(this.revisionId, this.justificationForm.value.justification!); + } + + this.justificationForm.markAllAsTouched(); } submit(): void { this.actions .updateRevision(this.revisionId, this.justificationForm.value.justification!) - .pipe( - tap(() => { - this.justificationForm.markAllAsTouched(); - this.router.navigate(['../1'], { - relativeTo: this.route, - onSameUrlNavigation: 'reload', - }); - }) - ) - .subscribe(); + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.justificationForm.markAllAsTouched(); + this.router.navigate(['../1'], { + relativeTo: this.route, + onSameUrlNavigation: 'reload', + }); + }); } deleteDraftUpdate() { @@ -96,29 +108,25 @@ export class JustificationStepComponent implements OnDestroy { messageKey: 'registries.justification.confirmDeleteUpdate.message', onConfirm: () => { const registrationId = this.schemaResponse()?.registrationId || ''; - this.actions.deleteSchemaResponse(this.revisionId).subscribe({ - next: () => { - this.isDraftDeleted = true; + this.actions + .deleteSchemaResponse(this.revisionId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.isDraftDeleted.set(true); this.actions.clearState(); this.toastService.showSuccess('registries.justification.successDeleteDraft'); this.router.navigateByUrl(`/${registrationId}/overview`); - }, - }); + }); }, }); } - ngOnDestroy(): void { - if (!this.isDraftDeleted) { - this.actions.updateStepState('0', this.justificationForm.invalid, true); - const changes = findChangedFields( - { justification: this.justificationForm.value.justification! }, - { justification: this.schemaResponse()?.revisionJustification } - ); - if (Object.keys(changes).length > 0) { - this.actions.updateRevision(this.revisionId, this.justificationForm.value.justification!); - } - this.justificationForm.markAllAsTouched(); - } + private setupInitialJustification() { + toObservable(this.schemaResponse) + .pipe( + filter((response) => !!response?.revisionJustification), + take(1) + ) + .subscribe((response) => this.justificationForm.patchValue({ justification: response!.revisionJustification })); } } diff --git a/src/app/features/registries/components/new-registration/new-registration.component.html b/src/app/features/registries/components/new-registration/new-registration.component.html index 8ce2cb21f..7c46cb5e9 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.html +++ b/src/app/features/registries/components/new-registration/new-registration.component.html @@ -16,25 +16,25 @@

{{ 'registries.new.steps.title' | translate }} 1

- @if (fromProject) { + @if (fromProject()) {

{{ 'registries.new.steps.title' | translate }} 2

{{ 'registries.new.steps.step2' | translate }}

@@ -49,7 +49,6 @@

{{ 'registries.new.steps.title' | translate }} 2

optionValue="id" filter="true" [loading]="isProjectsLoading()" - (onChange)="onSelectProject($event.value)" (onFilter)="onProjectFilter($event.filter)" class="w-6" /> @@ -58,7 +57,7 @@

{{ 'registries.new.steps.title' | translate }} 2

} -

{{ 'registries.new.steps.title' | translate }} {{ fromProject ? '3' : '2' }}

+

{{ 'registries.new.steps.title' | translate }} {{ fromProject() ? '3' : '2' }}

{{ 'registries.new.steps.step3' | translate }}

{{ 'registries.new.steps.title' | translate }} {{ fromProject ? optionLabel="name" optionValue="id" [loading]="isProvidersLoading()" - (onChange)="onSelectProviderSchema($event.value)" class="w-6" />
diff --git a/src/app/features/registries/components/new-registration/new-registration.component.spec.ts b/src/app/features/registries/components/new-registration/new-registration.component.spec.ts index ff8f9c3ee..c06634a3a 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.spec.ts +++ b/src/app/features/registries/components/new-registration/new-registration.component.spec.ts @@ -1,46 +1,48 @@ -import { MockComponent, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { of } from 'rxjs'; +import { MockComponent, MockProvider } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { CreateDraft, GetProjects, GetProviderSchemas, RegistriesSelectors } from '@osf/features/registries/store'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { GetRegistryProvider } from '@shared/stores/registration-provider'; import { NewRegistrationComponent } from './new-registration.component'; import { MOCK_PROVIDER_SCHEMAS } from '@testing/mocks/registries.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('NewRegistrationComponent', () => { let component: NewRegistrationComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; - const PROJECTS = [{ id: 'p1', title: 'P1' }]; - const PROVIDER_SCHEMAS = MOCK_PROVIDER_SCHEMAS; + let store: Store; + let mockRouter: RouterMockType; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create() + beforeEach(() => { + const mockActivatedRoute = ActivatedRouteMockBuilder.create() .withParams({ providerId: 'prov-1' }) .withQueryParams({ projectId: 'proj-1' }) .build(); mockRouter = RouterMockBuilder.create().withUrl('/x').build(); - await TestBed.configureTestingModule({ - imports: [NewRegistrationComponent, OSFTestingModule, MockComponent(SubHeaderComponent)], + TestBed.configureTestingModule({ + imports: [NewRegistrationComponent, MockComponent(SubHeaderComponent)], providers: [ + provideOSFCore(), MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(ToastService), MockProvider(Router, mockRouter), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getProjects, value: PROJECTS }, - { selector: RegistriesSelectors.getProviderSchemas, value: PROVIDER_SCHEMAS }, + { selector: RegistriesSelectors.getProjects, value: [{ id: 'p1', title: 'P1' }] }, + { selector: RegistriesSelectors.getProviderSchemas, value: MOCK_PROVIDER_SCHEMAS }, { selector: RegistriesSelectors.isDraftSubmitting, value: false }, { selector: RegistriesSelectors.getDraftRegistration, value: { id: 'draft-1' } }, { selector: RegistriesSelectors.isProvidersLoading, value: false }, @@ -49,8 +51,9 @@ describe('NewRegistrationComponent', () => { ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(NewRegistrationComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -60,44 +63,92 @@ describe('NewRegistrationComponent', () => { expect(component).toBeTruthy(); }); - it('should init with provider and project ids from route', () => { - expect(component.providerId).toBe('prov-1'); - expect(component.projectId).toBe('proj-1'); - expect(component.fromProject).toBe(true); + it('should dispatch initial data fetching on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(new GetProjects('user-1', '')); + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistryProvider('prov-1')); + expect(store.dispatch).toHaveBeenCalledWith(new GetProviderSchemas('prov-1')); }); - it('should default providerSchema when empty', () => { - expect(component['draftForm'].get('providerSchema')?.value).toBe('schema-1'); + it('should init fromProject as true when projectId is present', () => { + expect(component.fromProject()).toBe(true); }); - it('should update project on selection', () => { - component.onSelectProject('p1'); - expect(component['draftForm'].get('project')?.value).toBe('p1'); + it('should init form with project id from route', () => { + expect(component.draftForm.get('project')?.value).toBe('proj-1'); + }); + + it('should default providerSchema when schemas are available', () => { + expect(component.draftForm.get('providerSchema')?.value).toBe('schema-1'); }); it('should toggle fromProject and add/remove validator', () => { - component.fromProject = false; + component.fromProject.set(false); component.toggleFromProject(); - expect(component.fromProject).toBe(true); + expect(component.fromProject()).toBe(true); + expect(component.draftForm.get('project')?.validator).toBeTruthy(); + component.toggleFromProject(); - expect(component.fromProject).toBe(false); + expect(component.fromProject()).toBe(false); + expect(component.draftForm.get('project')?.validator).toBeNull(); }); - it('should create draft when form valid', () => { - const mockActions = { - createDraft: jest.fn().mockReturnValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); - + it('should dispatch createDraft and navigate when form is valid', () => { component.draftForm.patchValue({ providerSchema: 'schema-1', project: 'proj-1' }); - component.fromProject = true; + component.fromProject.set(true); + (store.dispatch as jest.Mock).mockClear(); + component.createDraft(); - expect(mockActions.createDraft).toHaveBeenCalledWith({ - registrationSchemaId: 'schema-1', - provider: 'prov-1', - projectId: 'proj-1', - }); + expect(store.dispatch).toHaveBeenCalledWith( + new CreateDraft({ registrationSchemaId: 'schema-1', provider: 'prov-1', projectId: 'proj-1' }) + ); expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/drafts/', 'draft-1', 'metadata']); }); + + it('should not dispatch createDraft when form is invalid', () => { + component.draftForm.patchValue({ providerSchema: '' }); + (store.dispatch as jest.Mock).mockClear(); + + component.createDraft(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(CreateDraft)); + }); + + it('should dispatch getProjects after debounced filter', fakeAsync(() => { + (store.dispatch as jest.Mock).mockClear(); + + component.onProjectFilter('abc'); + tick(300); + + expect(store.dispatch).toHaveBeenCalledWith(new GetProjects('user-1', 'abc')); + })); + + it('should not dispatch duplicate getProjects for same filter value', fakeAsync(() => { + (store.dispatch as jest.Mock).mockClear(); + + component.onProjectFilter('abc'); + tick(300); + component.onProjectFilter('abc'); + tick(300); + + const getProjectsCalls = (store.dispatch as jest.Mock).mock.calls.filter( + ([action]: [any]) => action instanceof GetProjects + ); + expect(getProjectsCalls.length).toBe(1); + })); + + it('should debounce rapid filter calls and dispatch only the last value', fakeAsync(() => { + (store.dispatch as jest.Mock).mockClear(); + + component.onProjectFilter('a'); + component.onProjectFilter('ab'); + component.onProjectFilter('abc'); + tick(300); + + const getProjectsCalls = (store.dispatch as jest.Mock).mock.calls.filter( + ([action]: [any]) => action instanceof GetProjects + ); + expect(getProjectsCalls.length).toBe(1); + expect(getProjectsCalls[0][0]).toEqual(new GetProjects('user-1', 'abc')); + })); }); diff --git a/src/app/features/registries/components/new-registration/new-registration.component.ts b/src/app/features/registries/components/new-registration/new-registration.component.ts index 952ee73f8..62e4b8e61 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.ts +++ b/src/app/features/registries/components/new-registration/new-registration.component.ts @@ -6,10 +6,10 @@ import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; import { Select } from 'primeng/select'; -import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, filter, Subject, take } from 'rxjs'; -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; @@ -32,93 +32,95 @@ export class NewRegistrationComponent { private readonly toastService = inject(ToastService); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); - private destroyRef = inject(DestroyRef); + private readonly destroyRef = inject(DestroyRef); + readonly user = select(UserSelectors.getCurrentUser); readonly projects = select(RegistriesSelectors.getProjects); readonly providerSchemas = select(RegistriesSelectors.getProviderSchemas); readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); - readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); readonly isProvidersLoading = select(RegistriesSelectors.isProvidersLoading); readonly isProjectsLoading = select(RegistriesSelectors.isProjectsLoading); - readonly user = select(UserSelectors.getCurrentUser); - actions = createDispatchMap({ + private readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + + private readonly actions = createDispatchMap({ getProvider: GetRegistryProvider, getProjects: GetProjects, getProviderSchemas: GetProviderSchemas, createDraft: CreateDraft, }); + private readonly providerId = this.route.snapshot.params['providerId']; + private readonly projectId = this.route.snapshot.queryParams['projectId']; + private readonly filter$ = new Subject(); - readonly providerId = this.route.snapshot.params['providerId']; - readonly projectId = this.route.snapshot.queryParams['projectId']; - - fromProject = this.projectId !== undefined; - - draftForm = this.fb.group({ + readonly fromProject = signal(this.projectId !== undefined); + readonly draftForm = this.fb.group({ providerSchema: ['', Validators.required], project: [this.projectId || ''], }); - private filter$ = new Subject(); - constructor() { - const userId = this.user()?.id; - if (userId) { - this.actions.getProjects(userId, ''); - } - this.actions.getProvider(this.providerId); - this.actions.getProviderSchemas(this.providerId); - effect(() => { - const providerSchema = this.draftForm.get('providerSchema')?.value; - if (!providerSchema) { - this.draftForm.get('providerSchema')?.setValue(this.providerSchemas()[0]?.id); - } - }); - - this.filter$ - .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) - .subscribe((value: string) => { - if (userId) { - this.actions.getProjects(userId, value); - } - }); - } - - onSelectProject(projectId: string) { - this.draftForm.patchValue({ - project: projectId, - }); + this.loadInitialData(); + this.setupDefaultSchema(); + this.setupProjectFilter(); } onProjectFilter(value: string) { this.filter$.next(value); } - onSelectProviderSchema(providerSchemaId: string) { - this.draftForm.patchValue({ - providerSchema: providerSchemaId, - }); - } - toggleFromProject() { - this.fromProject = !this.fromProject; - this.draftForm.get('project')?.setValidators(this.fromProject ? Validators.required : null); - this.draftForm.get('project')?.updateValueAndValidity(); + this.fromProject.update((v) => !v); + const projectControl = this.draftForm.get('project'); + projectControl?.setValidators(this.fromProject() ? Validators.required : null); + projectControl?.updateValueAndValidity(); } createDraft() { + if (this.draftForm.invalid) { + return; + } + const { providerSchema, project } = this.draftForm.value; - if (this.draftForm.valid) { - this.actions - .createDraft({ - registrationSchemaId: providerSchema!, - provider: this.providerId, - projectId: this.fromProject ? (project ?? undefined) : undefined, - }) - .subscribe(() => { - this.toastService.showSuccess('registries.new.createdSuccessfully'); - this.router.navigate(['/registries/drafts/', this.draftRegistration()?.id, 'metadata']); - }); + this.actions + .createDraft({ + registrationSchemaId: providerSchema!, + provider: this.providerId, + projectId: this.fromProject() ? (project ?? undefined) : undefined, + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.toastService.showSuccess('registries.new.createdSuccessfully'); + this.router.navigate(['/registries/drafts/', this.draftRegistration()!.id, 'metadata']); + }); + } + + private loadInitialData() { + const userId = this.user()?.id; + if (userId) { + this.actions.getProjects(userId, ''); } + this.actions.getProvider(this.providerId); + this.actions.getProviderSchemas(this.providerId); + } + + private setupDefaultSchema() { + toObservable(this.providerSchemas) + .pipe( + filter((schemas) => schemas.length > 0), + take(1) + ) + .subscribe((schemas) => this.draftForm.get('providerSchema')?.setValue(schemas[0].id)); + } + + private setupProjectFilter() { + this.filter$ + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((value: string) => { + const currentUserId = this.user()?.id; + if (currentUserId) { + this.actions.getProjects(currentUserId, value); + } + }); } } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.spec.ts index 551d5a24d..4cc42b958 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.spec.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.spec.ts @@ -1,13 +1,15 @@ import { Store } from '@ngxs/store'; -import { MockComponents, MockModule, ngMocks } from 'ng-mocks'; +import { MockComponents, MockModule, MockProvider, ngMocks } from 'ng-mocks'; import { TextareaModule } from 'primeng/textarea'; import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; import { SubjectsSelectors } from '@osf/shared/stores/subjects'; @@ -20,14 +22,14 @@ import { RegistriesSubjectsComponent } from './registries-subjects/registries-su import { RegistriesTagsComponent } from './registries-tags/registries-tags.component'; import { RegistriesMetadataStepComponent } from './registries-metadata-step.component'; -import { - CustomConfirmationServiceMock, - MockCustomConfirmationServiceProvider, -} from '@testing/mocks/custom-confirmation.service.mock'; import { MOCK_DRAFT_REGISTRATION } from '@testing/mocks/draft-registration.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; -import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesMetadataStepComponent', () => { @@ -38,6 +40,7 @@ describe('RegistriesMetadataStepComponent', () => { let store: Store; let mockRouter: RouterMockType; let stepsStateSignal: WritableSignal<{ invalid: boolean }[]>; + let customConfirmationService: CustomConfirmationServiceMockType; const mockDraft = { ...MOCK_DRAFT_REGISTRATION, title: 'Test Title', description: 'Test Description' }; @@ -45,6 +48,7 @@ describe('RegistriesMetadataStepComponent', () => { const mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); mockRouter = RouterMockBuilder.create().withUrl('/registries/osf/draft/draft-1/metadata').build(); stepsStateSignal = signal<{ invalid: boolean }[]>([{ invalid: true }]); + customConfirmationService = CustomConfirmationServiceMock.simple(); TestBed.configureTestingModule({ imports: [ @@ -61,9 +65,9 @@ describe('RegistriesMetadataStepComponent', () => { ], providers: [ provideOSFCore(), - provideActivatedRouteMock(mockActivatedRoute), - provideRouterMock(mockRouter), - MockCustomConfirmationServiceProvider, + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(CustomConfirmationService, customConfirmationService), provideMockStore({ signals: [ { selector: RegistriesSelectors.getDraftRegistration, value: mockDraft }, @@ -128,7 +132,7 @@ describe('RegistriesMetadataStepComponent', () => { it('should call confirmDelete when deleteDraft is called', () => { component.deleteDraft(); - expect(CustomConfirmationServiceMock.confirmDelete).toHaveBeenCalledWith( + expect(customConfirmationService.confirmDelete).toHaveBeenCalledWith( expect.objectContaining({ headerKey: 'registries.deleteDraft', messageKey: 'registries.confirmDeleteDraft', @@ -137,7 +141,7 @@ describe('RegistriesMetadataStepComponent', () => { }); it('should set isDraftDeleted and navigate on deleteDraft confirm', () => { - CustomConfirmationServiceMock.confirmDelete.mockImplementation(({ onConfirm }: any) => onConfirm()); + customConfirmationService.confirmDelete.mockImplementation(({ onConfirm }: any) => onConfirm()); (store.dispatch as jest.Mock).mockClear(); component.deleteDraft(); diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts index dd8165953..bc9d6e446 100644 --- a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts @@ -4,31 +4,58 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; +import { BrandService } from '@osf/shared/services/brand.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; +import { RegistryProviderDetails } from '@shared/models/provider/registry-provider.model'; import { RegistryProviderHeroComponent } from './registry-provider-hero.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; describe('RegistryProviderHeroComponent', () => { let component: RegistryProviderHeroComponent; let fixture: ComponentFixture; - let mockCustomDialogService: ReturnType; + let mockRouter: RouterMockType; + let mockDialog: CustomDialogServiceMockType; + let mockBrandService: { applyBranding: jest.Mock; resetBranding: jest.Mock }; + let mockHeaderStyleService: { applyHeaderStyles: jest.Mock; resetToDefaults: jest.Mock }; - beforeEach(async () => { - const mockRouter = RouterMockBuilder.create().withUrl('/x').build(); - mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - await TestBed.configureTestingModule({ - imports: [RegistryProviderHeroComponent, OSFTestingModule, MockComponent(SearchInputComponent)], - providers: [MockProvider(Router, mockRouter), MockProvider(CustomDialogService, mockCustomDialogService)], - }).compileComponents(); + const mockProvider: RegistryProviderDetails = { + id: 'prov-1', + name: 'Provider', + descriptionHtml: '', + permissions: [], + brand: null, + iri: '', + reviewsWorkflow: '', + }; + + beforeEach(() => { + mockRouter = RouterMockBuilder.create().withUrl('/x').build(); + mockDialog = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + mockBrandService = { applyBranding: jest.fn(), resetBranding: jest.fn() }; + mockHeaderStyleService = { applyHeaderStyles: jest.fn(), resetToDefaults: jest.fn() }; + + TestBed.configureTestingModule({ + imports: [RegistryProviderHeroComponent, MockComponent(SearchInputComponent)], + providers: [ + provideOSFCore(), + MockProvider(Router, mockRouter), + MockProvider(CustomDialogService, mockDialog), + MockProvider(BrandService, mockBrandService), + MockProvider(HeaderStyleService, mockHeaderStyleService), + ], + }); fixture = TestBed.createComponent(RegistryProviderHeroComponent); component = fixture.componentInstance; - - fixture.componentRef.setInput('provider', { id: 'prov-1', title: 'Provider', brand: undefined } as any); + fixture.componentRef.setInput('provider', mockProvider); fixture.componentRef.setInput('isProviderLoading', false); fixture.detectChanges(); }); @@ -45,24 +72,47 @@ describe('RegistryProviderHeroComponent', () => { it('should open help dialog', () => { component.openHelpDialog(); - expect(mockCustomDialogService.open).toHaveBeenCalledWith(expect.any(Function), { + expect(mockDialog.open).toHaveBeenCalledWith(expect.any(Function), { header: 'preprints.helpDialog.header', }); }); it('should navigate to create page when provider id present', () => { - const router = TestBed.inject(Router); - const navSpy = jest.spyOn(router, 'navigate'); - fixture.componentRef.setInput('provider', { id: 'prov-1', title: 'Provider', brand: undefined } as any); component.navigateToCreatePage(); - expect(navSpy).toHaveBeenCalledWith(['/registries/prov-1/new']); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/prov-1/new']); }); it('should not navigate when provider id missing', () => { - const router = TestBed.inject(Router); - const navSpy = jest.spyOn(router, 'navigate'); - fixture.componentRef.setInput('provider', { id: undefined, title: 'Provider', brand: undefined } as any); + fixture.componentRef.setInput('provider', { ...mockProvider, id: undefined }); component.navigateToCreatePage(); - expect(navSpy).not.toHaveBeenCalled(); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should apply branding and header styles when provider has brand', () => { + const brand = { + primaryColor: '#111', + secondaryColor: '#222', + backgroundColor: '#333', + topNavLogoImageUrl: 'logo.png', + heroBackgroundImageUrl: 'hero.png', + }; + + fixture.componentRef.setInput('provider', { ...mockProvider, brand }); + fixture.detectChanges(); + + expect(mockBrandService.applyBranding).toHaveBeenCalledWith(brand); + expect(mockHeaderStyleService.applyHeaderStyles).toHaveBeenCalledWith('#ffffff', '#111', 'hero.png'); + }); + + it('should not apply branding when provider has no brand', () => { + expect(mockBrandService.applyBranding).not.toHaveBeenCalled(); + expect(mockHeaderStyleService.applyHeaderStyles).not.toHaveBeenCalled(); + }); + + it('should reset branding and header styles on destroy', () => { + component.ngOnDestroy(); + + expect(mockHeaderStyleService.resetToDefaults).toHaveBeenCalled(); + expect(mockBrandService.resetBranding).toHaveBeenCalled(); }); }); diff --git a/src/app/features/registries/components/registry-services/registry-services.component.spec.ts b/src/app/features/registries/components/registry-services/registry-services.component.spec.ts index bf13f3b1d..a5878279c 100644 --- a/src/app/features/registries/components/registry-services/registry-services.component.spec.ts +++ b/src/app/features/registries/components/registry-services/registry-services.component.spec.ts @@ -1,17 +1,21 @@ +import { MockProvider } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; import { RegistryServicesComponent } from './registry-services.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('RegistryServicesComponent', () => { let component: RegistryServicesComponent; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RegistryServicesComponent, OSFTestingModule], - }).compileComponents(); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RegistryServicesComponent], + providers: [provideOSFCore(), MockProvider(ActivatedRoute)], + }); fixture = TestBed.createComponent(RegistryServicesComponent); component = fixture.componentInstance; diff --git a/src/app/features/registries/components/review/review.component.html b/src/app/features/registries/components/review/review.component.html index 2cd5eeba4..89eaa06cc 100644 --- a/src/app/features/registries/components/review/review.component.html +++ b/src/app/features/registries/components/review/review.component.html @@ -4,7 +4,7 @@

{{ 'navigation.metadata' | translate }}

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

-

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

+

{{ draftRegistration()?.title }}

@if (!draftRegistration()?.title) {

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

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

@@ -16,7 +16,7 @@

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

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

-

{{ draftRegistration()?.description | fixSpecialChar }}

+

{{ draftRegistration()?.description }}

@if (!draftRegistration()?.description) {

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

@@ -120,13 +120,13 @@

{{ section.title }}

[label]="'common.buttons.back' | translate" severity="info" class="mr-2" - (click)="goBack()" + (onClick)="goBack()" > @@ -135,7 +135,7 @@

{{ section.title }}

data-test-goto-register [label]="'registries.review.register' | translate" [disabled]="registerButtonDisabled()" - (click)="confirmRegistration()" + (onClick)="confirmRegistration()" >
diff --git a/src/app/features/registries/components/review/review.component.spec.ts b/src/app/features/registries/components/review/review.component.spec.ts index 510605975..1771c7971 100644 --- a/src/app/features/registries/components/review/review.component.spec.ts +++ b/src/app/features/registries/components/review/review.component.spec.ts @@ -1,119 +1,435 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; +import { Subject } from 'rxjs'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { + ClearState, + DeleteDraft, + FetchLicenses, + FetchProjectChildren, + RegistriesSelectors, +} from '@osf/features/registries/store'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component'; import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.component'; -import { FieldType } from '@osf/shared/enums/field-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 { ContributorsSelectors } from '@osf/shared/stores/contributors'; -import { SubjectsSelectors } from '@osf/shared/stores/subjects'; +import { + ContributorsSelectors, + GetAllContributors, + LoadMoreContributors, + ResetContributorsState, +} from '@osf/shared/stores/contributors'; +import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; import { ReviewComponent } from './review.component'; -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 { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; 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'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +const DEFAULT_DRAFT = { + id: 'draft-1', + providerId: 'prov-1', + currentUserPermissions: [], + hasProject: false, + license: { options: {} }, + branchedFrom: { id: 'proj-1', type: 'nodes' }, +}; + +function createDefaultSignals(overrides: { selector: any; value: any }[] = []) { + const defaults = [ + { selector: RegistriesSelectors.getPagesSchema, value: [] }, + { selector: RegistriesSelectors.getDraftRegistration, value: DEFAULT_DRAFT }, + { selector: RegistriesSelectors.isDraftSubmitting, value: false }, + { selector: RegistriesSelectors.isDraftLoading, value: false }, + { selector: RegistriesSelectors.getStepsData, value: {} }, + { selector: RegistriesSelectors.getRegistrationComponents, value: [] }, + { selector: RegistriesSelectors.getRegistrationLicense, value: null }, + { selector: RegistriesSelectors.getRegistration, value: { id: 'new-reg-1' } }, + { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false } } }, + { selector: RegistriesSelectors.hasDraftAdminAccess, value: true }, + { selector: ContributorsSelectors.getContributors, value: [] }, + { selector: ContributorsSelectors.isContributorsLoading, value: false }, + { selector: ContributorsSelectors.hasMoreContributors, value: false }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, + ]; + + return overrides.length + ? defaults.map((s) => { + const override = overrides.find((o) => o.selector === s.selector); + return override ? { ...s, value: override.value } : s; + }) + : defaults; +} + +function setup( + opts: { + selectorOverrides?: { selector: any; value: any }[]; + dialogCloseSubject?: Subject; + } = {} +) { + const mockRouter = RouterMockBuilder.create().withUrl('/registries/123/review').build(); + const mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + + const dialogClose$ = opts.dialogCloseSubject ?? new Subject(); + const mockDialog = CustomDialogServiceMockBuilder.create() + .withOpen( + jest.fn().mockReturnValue({ + onClose: dialogClose$.pipe(), + close: jest.fn(), + }) + ) + .build(); + + const mockToast = ToastServiceMock.simple(); + const mockConfirmation = CustomConfirmationServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [ + ReviewComponent, + ...MockComponents(RegistrationBlocksDataComponent, ContributorsListComponent, LicenseDisplayComponent), + ], + providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(CustomDialogService, mockDialog), + MockProvider(CustomConfirmationService, mockConfirmation), + MockProvider(ToastService, mockToast), + provideMockStore({ signals: createDefaultSignals(opts.selectorOverrides) }), + ], + }); + + const store = TestBed.inject(Store); + const fixture = TestBed.createComponent(ReviewComponent); + const component = fixture.componentInstance; + fixture.detectChanges(); + + return { fixture, component, store, mockRouter, mockDialog, mockToast, mockConfirmation, dialogClose$ }; +} describe('ReviewComponent', () => { let component: ReviewComponent; - let fixture: ComponentFixture; - let mockRouter: ReturnType; - let mockActivatedRoute: ReturnType; - let mockDialog: ReturnType; - let mockConfirm: ReturnType; - let mockToast: ReturnType; - - beforeEach(async () => { - mockRouter = RouterMockBuilder.create().withUrl('/registries/123/review').build(); - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); - - mockDialog = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - mockConfirm = CustomConfirmationServiceMockBuilder.create() - .withConfirmDelete(jest.fn((opts) => opts.onConfirm && opts.onConfirm())) - .build(); - mockToast = ToastServiceMockBuilder.create().build(); - - await TestBed.configureTestingModule({ - imports: [ - ReviewComponent, - OSFTestingModule, - ...MockComponents(RegistrationBlocksDataComponent, ContributorsListComponent, LicenseDisplayComponent), - ], - providers: [ - MockProvider(Router, mockRouter), - MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(CustomDialogService, mockDialog), - MockProvider(CustomConfirmationService, mockConfirm), - MockProvider(ToastService, mockToast), - provideMockStore({ - signals: [ - { selector: RegistriesSelectors.getPagesSchema, value: [] }, - { - selector: RegistriesSelectors.getDraftRegistration, - value: { id: 'draft-1', providerId: 'prov-1', currentUserPermissions: [], hasProject: false }, - }, - { selector: RegistriesSelectors.isDraftSubmitting, value: false }, - { selector: RegistriesSelectors.isDraftLoading, value: false }, - { selector: RegistriesSelectors.getStepsData, value: {} }, - { selector: RegistriesSelectors.getRegistrationComponents, value: [] }, - { selector: RegistriesSelectors.getRegistrationLicense, value: null }, - { selector: RegistriesSelectors.getRegistration, value: { id: 'new-reg-1' } }, - { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false } } }, - { selector: ContributorsSelectors.getContributors, value: [] }, - { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, - ], - }), - ], - }).compileComponents(); + let store: Store; + let mockRouter: RouterMockType; + let mockDialog: CustomDialogServiceMockType; + let mockToast: ToastServiceMockType; + let mockConfirmation: CustomConfirmationServiceMockType; + let dialogClose$: Subject; - fixture = TestBed.createComponent(ReviewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + beforeEach(() => { + const result = setup(); + component = result.component; + store = result.store; + mockRouter = result.mockRouter; + mockDialog = result.mockDialog; + mockToast = result.mockToast; + mockConfirmation = result.mockConfirmation; + dialogClose$ = result.dialogClose$; }); it('should create', () => { expect(component).toBeTruthy(); - expect(component.FieldType).toBe(FieldType); }); - it('should navigate back to previous step', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + it('should dispatch getContributors, getSubjects and fetchLicenses on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(new GetAllContributors('draft-1', ResourceType.DraftRegistration)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSelectedSubjects('draft-1', ResourceType.DraftRegistration)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses('prov-1')); + }); + + it('should navigate to previous step on goBack', () => { + const { component: c, mockRouter: router } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getPagesSchema, value: [{ id: '1' }, { id: '2' }] }], + }); + + c.goBack(); + + expect(router.navigate).toHaveBeenCalledWith( + ['../', 2], + expect.objectContaining({ relativeTo: expect.anything() }) + ); + }); + + it('should navigate to step 0 when pages is empty on goBack', () => { component.goBack(); - expect(navSpy).toHaveBeenCalledWith(['../', 0], { relativeTo: TestBed.inject(ActivatedRoute) }); + + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 0], + expect.objectContaining({ relativeTo: expect.anything() }) + ); }); - it('should open confirmation dialog when deleting draft and navigate on confirm', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigateByUrl'); - (component as any).actions = { - ...component.actions, - deleteDraft: jest.fn().mockReturnValue(of({})), - clearState: jest.fn(), - }; + it('should dispatch deleteDraft and navigate on confirm', () => { + mockConfirmation.confirmDelete.mockImplementation(({ onConfirm }: any) => onConfirm()); + (store.dispatch as jest.Mock).mockClear(); component.deleteDraft(); - expect(mockConfirm.confirmDelete).toHaveBeenCalled(); - expect(navSpy).toHaveBeenCalledWith('/registries/prov-1/new'); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteDraft('draft-1')); + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/registries/prov-1/new'); + }); + + it('should open select components dialog when components exist', () => { + const { component: c, mockDialog: dialog } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }], + }); + + c.confirmRegistration(); + + expect(dialog.open).toHaveBeenCalled(); + const firstCallArgs = (dialog.open as jest.Mock).mock.calls[0]; + expect(firstCallArgs[1].header).toBe('registries.review.selectComponents.title'); }); - it('should open select components dialog when components exist and chain to confirm', () => { - (component as any).components = () => ['c1', 'c2']; - (mockDialog.open as jest.Mock).mockReturnValueOnce({ onClose: of(['c1']) } as any); + it('should open confirm registration dialog when no components', () => { component.confirmRegistration(); expect(mockDialog.open).toHaveBeenCalled(); - expect((mockDialog.open as jest.Mock).mock.calls.length).toBeGreaterThan(1); + const firstCallArgs = (mockDialog.open as jest.Mock).mock.calls[0]; + expect(firstCallArgs[1].header).toBe('registries.review.confirmation.title'); + }); + + it('should show success toast and navigate on successful registration', () => { + component.openConfirmRegistrationDialog(); + dialogClose$.next(true); + + expect(mockToast.showSuccess).toHaveBeenCalledWith('registries.review.confirmation.successMessage'); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/new-reg-1/overview']); + }); + + it('should reopen select components dialog when confirm dialog closed with falsy result and components exist', () => { + const { + component: c, + mockDialog: dialog, + dialogClose$: close$, + } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }], + }); + + c.openConfirmRegistrationDialog(['comp-1']); + close$.next(false); + + expect(dialog.open).toHaveBeenCalledTimes(2); + }); + + it('should not navigate when confirm dialog closed with falsy result and no components', () => { + component.openConfirmRegistrationDialog(); + dialogClose$.next(false); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should pass selected components from select dialog to confirm dialog', () => { + const selectClose$ = new Subject(); + const confirmClose$ = new Subject(); + let callCount = 0; + + const { component: c, mockDialog: dialog } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }], + }); + + (dialog.open as jest.Mock).mockImplementation(() => { + callCount++; + const subj = callCount === 1 ? selectClose$ : confirmClose$; + return { onClose: subj.pipe(), close: jest.fn() }; + }); + + c.openSelectComponentsForRegistrationDialog(); + selectClose$.next(['comp-1']); + + expect(dialog.open).toHaveBeenCalledTimes(2); + const secondCallArgs = (dialog.open as jest.Mock).mock.calls[1]; + expect(secondCallArgs[1].data.components).toEqual(['comp-1']); + }); + + it('should not open confirm dialog when select components dialog returns falsy', () => { + const selectClose$ = new Subject(); + + const { component: c, mockDialog: dialog } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }], + }); + + (dialog.open as jest.Mock).mockReturnValue({ + onClose: selectClose$.pipe(), + close: jest.fn(), + }); + + c.openSelectComponentsForRegistrationDialog(); + selectClose$.next(null); + + expect(dialog.open).toHaveBeenCalledTimes(1); + }); + + it('should dispatch loadMoreContributors', () => { + (store.dispatch as jest.Mock).mockClear(); + component.loadMoreContributors(); + expect(store.dispatch).toHaveBeenCalledWith(new LoadMoreContributors('draft-1', ResourceType.DraftRegistration)); + }); + + it('should dispatch resetContributorsState on destroy', () => { + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(new ResetContributorsState()); + }); + + it('should compute isDraftInvalid as false when all steps are valid', () => { + expect(component.isDraftInvalid()).toBe(false); + }); + + it('should compute isDraftInvalid as true when any step is invalid', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: true } } }], + }); + expect(c.isDraftInvalid()).toBe(true); + }); + + it('should compute registerButtonDisabled as false when valid and has admin access', () => { + expect(component.registerButtonDisabled()).toBe(false); + }); + + it('should compute registerButtonDisabled as true when draft is loading', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.isDraftLoading, value: true }], + }); + expect(c.registerButtonDisabled()).toBe(true); + }); + + it('should compute registerButtonDisabled as true when draft is invalid', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: true } } }], + }); + expect(c.registerButtonDisabled()).toBe(true); + }); + + it('should compute registerButtonDisabled as true when no admin access', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.hasDraftAdminAccess, value: false }], + }); + expect(c.registerButtonDisabled()).toBe(true); + }); + + it('should compute licenseOptionsRecord from draft license options', () => { + const { component: c } = setup({ + selectorOverrides: [ + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...DEFAULT_DRAFT, license: { options: { year: '2026', copyright: 'Test' } } }, + }, + ], + }); + expect(c.licenseOptionsRecord()).toEqual({ year: '2026', copyright: 'Test' }); + }); + + it('should compute licenseOptionsRecord as empty when no license options', () => { + expect(component.licenseOptionsRecord()).toEqual({}); + }); + + it('should pass draftId and providerId to confirm registration dialog data', () => { + component.openConfirmRegistrationDialog(); + + const callArgs = (mockDialog.open as jest.Mock).mock.calls[0]; + expect(callArgs[1].data.draftId).toBe('draft-1'); + expect(callArgs[1].data.providerId).toBe('prov-1'); + expect(callArgs[1].data.projectId).toBe('proj-1'); + }); + + it('should set projectId to null when branchedFrom type is not nodes', () => { + const { component: c, mockDialog: dialog } = setup({ + selectorOverrides: [ + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...DEFAULT_DRAFT, branchedFrom: { id: 'proj-1', type: 'registrations' } }, + }, + ], + }); + + c.openConfirmRegistrationDialog(); + + const callArgs = (dialog.open as jest.Mock).mock.calls[0]; + expect(callArgs[1].data.projectId).toBeNull(); + }); + + it('should pass components array to confirm registration dialog', () => { + component.openConfirmRegistrationDialog(['comp-1', 'comp-2']); + + const callArgs = (mockDialog.open as jest.Mock).mock.calls[0]; + expect(callArgs[1].data.components).toEqual(['comp-1', 'comp-2']); + }); + + it('should not navigate after registration when newRegistration has no id', () => { + const { + component: c, + mockRouter: router, + mockToast: toast, + dialogClose$: close$, + } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistration, value: { id: null } }], + }); + + c.openConfirmRegistrationDialog(); + close$.next(true); + + expect(toast.showSuccess).toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should dispatch getProjectsComponents when draft hasProject is true', () => { + const { store: s } = setup({ + selectorOverrides: [ + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...DEFAULT_DRAFT, hasProject: true }, + }, + ], + }); + + expect(s.dispatch).toHaveBeenCalledWith(new FetchProjectChildren('proj-1')); + }); + + it('should dispatch getProjectsComponents with empty string when branchedFrom has no id', () => { + const { store: s } = setup({ + selectorOverrides: [ + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...DEFAULT_DRAFT, hasProject: true, branchedFrom: null }, + }, + ], + }); + + expect(s.dispatch).toHaveBeenCalledWith(new FetchProjectChildren('')); + }); + + it('should not dispatch getProjectsComponents when isDraftSubmitting is true', () => { + const { store: s } = setup({ + selectorOverrides: [ + { selector: RegistriesSelectors.isDraftSubmitting, value: true }, + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...DEFAULT_DRAFT, hasProject: true }, + }, + ], + }); + + expect(s.dispatch).not.toHaveBeenCalledWith(expect.any(FetchProjectChildren)); }); }); diff --git a/src/app/features/registries/components/review/review.component.ts b/src/app/features/registries/components/review/review.component.ts index 0d9f2c339..bc634acbb 100644 --- a/src/app/features/registries/components/review/review.component.ts +++ b/src/app/features/registries/components/review/review.component.ts @@ -7,10 +7,19 @@ import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; import { Tag } from 'primeng/tag'; -import { map, of } from 'rxjs'; +import { filter, map } from 'rxjs'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, OnDestroy } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + OnDestroy, + signal, +} from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -18,10 +27,7 @@ import { ContributorsListComponent } from '@osf/shared/components/contributors-l import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component'; import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.component'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; -import { FieldType } from '@osf/shared/enums/field-type.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe'; 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'; @@ -33,14 +39,7 @@ import { } from '@osf/shared/stores/contributors'; import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; -import { - ClearState, - DeleteDraft, - FetchLicenses, - FetchProjectChildren, - RegistriesSelectors, - UpdateStepState, -} from '../../store'; +import { ClearState, DeleteDraft, FetchLicenses, FetchProjectChildren, RegistriesSelectors } from '../../store'; import { ConfirmRegistrationDialogComponent } from '../confirm-registration-dialog/confirm-registration-dialog.component'; import { SelectComponentsDialogComponent } from '../select-components-dialog/select-components-dialog.component'; @@ -55,7 +54,6 @@ import { SelectComponentsDialogComponent } from '../select-components-dialog/sel RegistrationBlocksDataComponent, ContributorsListComponent, LicenseDisplayComponent, - FixSpecialCharPipe, ], templateUrl: './review.component.html', styleUrl: './review.component.scss', @@ -67,6 +65,7 @@ export class ReviewComponent implements OnDestroy { private readonly customConfirmationService = inject(CustomConfirmationService); private readonly customDialogService = inject(CustomDialogService); private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); private readonly environment = inject(ENVIRONMENT); readonly pages = select(RegistriesSelectors.getPagesSchema); @@ -74,68 +73,65 @@ export class ReviewComponent implements OnDestroy { readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); readonly isDraftLoading = select(RegistriesSelectors.isDraftLoading); readonly stepsData = select(RegistriesSelectors.getStepsData); - readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; + readonly components = select(RegistriesSelectors.getRegistrationComponents); + readonly license = select(RegistriesSelectors.getRegistrationLicense); + readonly newRegistration = select(RegistriesSelectors.getRegistration); + readonly stepsState = select(RegistriesSelectors.getStepsState); readonly contributors = select(ContributorsSelectors.getContributors); readonly areContributorsLoading = select(ContributorsSelectors.isContributorsLoading); readonly hasMoreContributors = select(ContributorsSelectors.hasMoreContributors); readonly subjects = select(SubjectsSelectors.getSelectedSubjects); - readonly components = select(RegistriesSelectors.getRegistrationComponents); - readonly license = select(RegistriesSelectors.getRegistrationLicense); - readonly newRegistration = select(RegistriesSelectors.getRegistration); + readonly hasAdminAccess = select(RegistriesSelectors.hasDraftAdminAccess); - readonly FieldType = FieldType; - - actions = createDispatchMap({ + private readonly actions = createDispatchMap({ getContributors: GetAllContributors, getSubjects: FetchSelectedSubjects, deleteDraft: DeleteDraft, clearState: ClearState, getProjectsComponents: FetchProjectChildren, fetchLicenses: FetchLicenses, - updateStepState: UpdateStepState, loadMoreContributors: LoadMoreContributors, resetContributorsState: ResetContributorsState, }); - private readonly draftId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); - - stepsState = select(RegistriesSelectors.getStepsState); - - isDraftInvalid = computed(() => Object.values(this.stepsState()).some((step) => step.invalid)); + readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - licenseOptionsRecord = computed(() => (this.draftRegistration()?.license.options ?? {}) as Record); + private readonly draftId = toSignal(this.route.params.pipe(map((params) => params['id']))); - hasAdminAccess = computed(() => { - const registry = this.draftRegistration(); - if (!registry) return false; - return registry.currentUserPermissions.includes(UserPermissions.Admin); + private readonly resolvedProviderId = computed(() => { + const draft = this.draftRegistration(); + return draft ? (draft.providerId ?? this.environment.defaultProvider) : undefined; }); + private readonly componentsLoaded = signal(false); + + isDraftInvalid = computed(() => Object.values(this.stepsState()).some((step) => step.invalid)); + licenseOptionsRecord = computed(() => (this.draftRegistration()?.license.options ?? {}) as Record); registerButtonDisabled = computed(() => this.isDraftLoading() || this.isDraftInvalid() || !this.hasAdminAccess()); constructor() { if (!this.contributors()?.length) { this.actions.getContributors(this.draftId(), ResourceType.DraftRegistration); } + if (!this.subjects()?.length) { this.actions.getSubjects(this.draftId(), ResourceType.DraftRegistration); } effect(() => { - if (this.draftRegistration()) { - this.actions.fetchLicenses(this.draftRegistration()?.providerId ?? this.environment.defaultProvider); + const providerId = this.resolvedProviderId(); + + if (providerId) { + this.actions.fetchLicenses(providerId); } }); - let componentsLoaded = false; effect(() => { - if (!this.isDraftSubmitting()) { - const draftRegistrations = this.draftRegistration(); - if (draftRegistrations?.hasProject) { - if (!componentsLoaded) { - this.actions.getProjectsComponents(draftRegistrations?.branchedFrom?.id ?? ''); - componentsLoaded = true; - } + if (!this.isDraftSubmitting() && !this.componentsLoaded()) { + const draft = this.draftRegistration(); + if (draft?.hasProject) { + this.actions.getProjectsComponents(draft.branchedFrom?.id ?? ''); + this.componentsLoaded.set(true); } } }); @@ -156,12 +152,13 @@ export class ReviewComponent implements OnDestroy { messageKey: 'registries.confirmDeleteDraft', onConfirm: () => { const providerId = this.draftRegistration()?.providerId; - this.actions.deleteDraft(this.draftId()).subscribe({ - next: () => { + this.actions + .deleteDraft(this.draftId()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { this.actions.clearState(); this.router.navigateByUrl(`/registries/${providerId}/new`); - }, - }); + }); }, }); } @@ -184,11 +181,11 @@ export class ReviewComponent implements OnDestroy { components: this.components(), }, }) - .onClose.subscribe((selectedComponents) => { - if (selectedComponents) { - this.openConfirmRegistrationDialog(selectedComponents); - } - }); + .onClose.pipe( + filter((selectedComponents) => !!selectedComponents), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((selectedComponents) => this.openConfirmRegistrationDialog(selectedComponents)); } openConfirmRegistrationDialog(components?: string[]): void { @@ -206,14 +203,16 @@ export class ReviewComponent implements OnDestroy { components, }, }) - .onClose.subscribe((res) => { + .onClose.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((res) => { if (res) { this.toastService.showSuccess('registries.review.confirmation.successMessage'); - this.router.navigate([`/${this.newRegistration()?.id}/overview`]); - } else { - if (this.components()?.length) { - this.openSelectComponentsForRegistrationDialog(); + const id = this.newRegistration()?.id; + if (id) { + this.router.navigate([`/${id}/overview`]); } + } else if (this.components()?.length) { + this.openSelectComponentsForRegistrationDialog(); } }); } diff --git a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html index 334e43284..bd927b1c2 100644 --- a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html +++ b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html @@ -13,7 +13,7 @@ class="w-12rem btn-full-width" [label]="'common.buttons.back' | translate" severity="info" - (click)="dialogRef.close()" + (onClick)="dialogRef.close()" /> - +
diff --git a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts index 69346c419..e698bf519 100644 --- a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts +++ b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts @@ -1,37 +1,39 @@ import { MockProvider } from 'ng-mocks'; +import { TreeNode } from 'primeng/api'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ProjectShortInfoModel } from '../../models/project-short-info.model'; + import { SelectComponentsDialogComponent } from './select-components-dialog.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('SelectComponentsDialogComponent', () => { let component: SelectComponentsDialogComponent; let fixture: ComponentFixture; - let dialogRefMock: { close: jest.Mock }; - let dialogConfigMock: DynamicDialogConfig; + let dialogRef: DynamicDialogRef; - const parent = { id: 'p1', title: 'Parent Project' } as any; - const components = [ + const parent: ProjectShortInfoModel = { id: 'p1', title: 'Parent Project' }; + const components: ProjectShortInfoModel[] = [ { id: 'c1', title: 'Child 1', children: [{ id: 'c1a', title: 'Child 1A' }] }, { id: 'c2', title: 'Child 2' }, - ] as any; - - beforeEach(async () => { - dialogRefMock = { close: jest.fn() } as any; - dialogConfigMock = { data: { parent, components } } as any; + ]; - await TestBed.configureTestingModule({ - imports: [SelectComponentsDialogComponent, OSFTestingModule], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [SelectComponentsDialogComponent], providers: [ - MockProvider(DynamicDialogRef, dialogRefMock as any), - MockProvider(DynamicDialogConfig, dialogConfigMock as any), + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { data: { parent, components } }), ], - }).compileComponents(); + }); + dialogRef = TestBed.inject(DynamicDialogRef); fixture = TestBed.createComponent(SelectComponentsDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -43,17 +45,14 @@ describe('SelectComponentsDialogComponent', () => { const root = component.components[0]; expect(root.label).toBe('Parent Project'); expect(root.children?.length).toBe(2); - const selectedKeys = new Set(component.selectedComponents.map((n) => n.key)); - expect(selectedKeys.has('p1')).toBe(true); - expect(selectedKeys.has('c1')).toBe(true); - expect(selectedKeys.has('c1a')).toBe(true); - expect(selectedKeys.has('c2')).toBe(true); + const selectedKeys = new Set(component.selectedComponents.map((n: TreeNode) => n.key)); + expect(selectedKeys).toEqual(new Set(['p1', 'c1', 'c1a', 'c2'])); }); it('should close with unique selected component ids including parent on continue', () => { component.continue(); - expect(dialogRefMock.close).toHaveBeenCalledWith(expect.arrayContaining(['p1', 'c1', 'c1a', 'c2'])); - const passed = (dialogRefMock.close as jest.Mock).mock.calls[0][0] as string[]; + expect(dialogRef.close).toHaveBeenCalledWith(expect.arrayContaining(['p1', 'c1', 'c1a', 'c2'])); + const passed = (dialogRef.close as jest.Mock).mock.calls[0][0] as string[]; expect(new Set(passed).size).toBe(passed.length); }); }); diff --git a/src/app/features/registries/models/attached-file.model.ts b/src/app/features/registries/models/attached-file.model.ts new file mode 100644 index 000000000..458dac9cf --- /dev/null +++ b/src/app/features/registries/models/attached-file.model.ts @@ -0,0 +1,3 @@ +import { FileModel } from '@osf/shared/models/files/file.model'; + +export type AttachedFile = Partial; diff --git a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts index c716f46fe..bd6fc8631 100644 --- a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts +++ b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts @@ -1,8 +1,9 @@ import { Store } from '@ngxs/store'; -import { MockComponent } from 'ng-mocks'; +import { MockComponent, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; import { RegistriesSelectors, UpdateDraft } from '@osf/features/registries/store'; import { DraftRegistrationModel } from '@osf/shared/models/registration/draft-registration.model'; @@ -13,8 +14,8 @@ import { DraftRegistrationCustomStepComponent } from './draft-registration-custo import { MOCK_REGISTRIES_PAGE } from '@testing/mocks/registries.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; -import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; const MOCK_DRAFT: Partial = { @@ -41,8 +42,8 @@ describe('DraftRegistrationCustomStepComponent', () => { imports: [DraftRegistrationCustomStepComponent, MockComponent(CustomStepComponent)], providers: [ provideOSFCore(), - provideActivatedRouteMock(mockRoute), - provideRouterMock(mockRouter), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(Router, mockRouter), provideMockStore({ signals: [ { selector: RegistriesSelectors.getStepsData, value: stepsData }, diff --git a/src/app/features/registries/pages/justification/justification.component.spec.ts b/src/app/features/registries/pages/justification/justification.component.spec.ts index 00b39b835..c69986e1e 100644 --- a/src/app/features/registries/pages/justification/justification.component.spec.ts +++ b/src/app/features/registries/pages/justification/justification.component.spec.ts @@ -1,15 +1,16 @@ import { Store } from '@ngxs/store'; -import { MockComponents } from 'ng-mocks'; +import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NavigationEnd } from '@angular/router'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; +import { LoaderService } from '@osf/shared/services/loader.service'; import { ClearState, FetchSchemaBlocks, FetchSchemaResponse, RegistriesSelectors } from '../../store'; @@ -17,9 +18,9 @@ import { JustificationComponent } from './justification.component'; import { createMockSchemaResponse } from '@testing/mocks/schema-response.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock'; -import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; -import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; const MOCK_SCHEMA_RESPONSE = createMockSchemaResponse('resp-1', RevisionReviewStates.RevisionInProgress); @@ -68,9 +69,9 @@ describe('JustificationComponent', () => { imports: [JustificationComponent, ...MockComponents(StepperComponent, SubHeaderComponent)], providers: [ provideOSFCore(), - provideActivatedRouteMock(mockRoute), - provideRouterMock(mockRouter), - provideLoaderServiceMock(loaderService), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(Router, mockRouter), + MockProvider(LoaderService, loaderService), provideMockStore({ signals: [ { selector: RegistriesSelectors.getSchemaResponse, value: schemaResponse }, diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts b/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts index b5bf6b208..1d2557ed9 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts @@ -1,9 +1,9 @@ import { Store } from '@ngxs/store'; -import { MockComponents } from 'ng-mocks'; +import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; import { RegistrationTab } from '@osf/features/registries/enums'; @@ -19,11 +19,12 @@ import { DeleteDraft, FetchDraftRegistrations, FetchSubmittedRegistrations } fro import { MyRegistrationsComponent } from './my-registrations.component'; -import { MockCustomConfirmationServiceProvider } from '@testing/mocks/custom-confirmation.service.mock'; -import { provideOSFCore, provideOSFToast } from '@testing/osf.testing.provider'; -import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; -import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { CustomConfirmationServiceMock } from '@testing/providers/custom-confirmation-provider.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; describe('MyRegistrationsComponent', () => { let component: MyRegistrationsComponent; @@ -45,10 +46,10 @@ describe('MyRegistrationsComponent', () => { ], providers: [ provideOSFCore(), - provideRouterMock(mockRouter), - provideActivatedRouteMock(mockRoute), - MockCustomConfirmationServiceProvider, - provideOSFToast(), + MockProvider(Router, mockRouter), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(CustomConfirmationService, CustomConfirmationServiceMock.simple()), + MockProvider(ToastService, ToastServiceMock.simple()), provideMockStore({ signals: [ { selector: RegistriesSelectors.getDraftRegistrations, value: [] }, diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts b/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts index 7520a2198..8d90557a1 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts @@ -1,9 +1,10 @@ import { Store } from '@ngxs/store'; -import { MockComponents } from 'ng-mocks'; +import { MockComponents, MockProvider } from 'ng-mocks'; import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; import { ScheduledBannerComponent } from '@core/components/osf-banners/scheduled-banner/scheduled-banner.component'; import { ClearCurrentProvider } from '@core/store/provider'; @@ -19,7 +20,7 @@ import { GetRegistries, RegistriesSelectors } from '../../store'; import { RegistriesLandingComponent } from './registries-landing.component'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesLandingComponent', () => { @@ -45,7 +46,7 @@ describe('RegistriesLandingComponent', () => { ], providers: [ provideOSFCore(), - provideRouterMock(mockRouter), + MockProvider(Router, mockRouter), { provide: PLATFORM_ID, useValue: 'browser' }, provideMockStore({ signals: [ diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts index 6f53ec22f..ea8d99376 100644 --- a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts @@ -1,9 +1,10 @@ import { Store } from '@ngxs/store'; -import { MockComponents } from 'ng-mocks'; +import { MockComponents, MockProvider } from 'ng-mocks'; import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; import { ClearCurrentProvider } from '@core/store/provider'; import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; @@ -21,7 +22,7 @@ import { RegistryProviderHeroComponent } from '../../components/registry-provide import { RegistriesProviderSearchComponent } from './registries-provider-search.component'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; const MOCK_PROVIDER: RegistryProviderDetails = { @@ -51,7 +52,7 @@ describe('RegistriesProviderSearchComponent', () => { ], providers: [ provideOSFCore(), - provideActivatedRouteMock(mockRoute), + MockProvider(ActivatedRoute, mockRoute), { provide: PLATFORM_ID, useValue: platformId }, provideMockStore({ signals: [ diff --git a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts index 86b056485..6aa227fc6 100644 --- a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts +++ b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts @@ -1,8 +1,9 @@ import { Store } from '@ngxs/store'; -import { MockComponents } from 'ng-mocks'; +import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; import { CustomStepComponent } from '../../components/custom-step/custom-step.component'; import { RegistriesSelectors, UpdateSchemaResponse } from '../../store'; @@ -10,8 +11,8 @@ import { RegistriesSelectors, UpdateSchemaResponse } from '../../store'; import { RevisionsCustomStepComponent } from './revisions-custom-step.component'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; -import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RevisionsCustomStepComponent', () => { @@ -28,8 +29,8 @@ describe('RevisionsCustomStepComponent', () => { imports: [RevisionsCustomStepComponent, MockComponents(CustomStepComponent)], providers: [ provideOSFCore(), - provideActivatedRouteMock(mockRoute), - provideRouterMock(mockRouter), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(Router, mockRouter), provideMockStore({ signals: [ { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 01ef03921..b6f5f392a 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1180,7 +1180,7 @@ "title": "Select Destination", "dialogTitle": "Move file", "dialogTitleMultiple": "Move files", - "message": "Are you sure you want to move {{dragNodeName}} to {{dropNodeName}} ?", + "message": "Are you sure you want to move {{dragNodeName}} to {{dropNodeName}}?", "multipleFiles": "{{count}} files", "storage": "OSF Storage", "pathError": "Path is not specified!", diff --git a/src/testing/mocks/data.mock.ts b/src/testing/mocks/data.mock.ts index 0d24b1261..f8503e40b 100644 --- a/src/testing/mocks/data.mock.ts +++ b/src/testing/mocks/data.mock.ts @@ -58,6 +58,7 @@ export const MOCK_USER: UserModel = { allowIndexing: true, canViewReviews: true, mergedBy: undefined, + external_identity: {}, }; export const MOCK_USER_RELATED_COUNTS: UserRelatedCounts = { diff --git a/src/testing/osf.testing.provider.ts b/src/testing/osf.testing.provider.ts index f3710e33a..5667c36a0 100644 --- a/src/testing/osf.testing.provider.ts +++ b/src/testing/osf.testing.provider.ts @@ -5,12 +5,8 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { importProvidersFrom } from '@angular/core'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; -import { provideDynamicDialogRefMock } from './mocks/dynamic-dialog-ref.mock'; import { EnvironmentTokenMock } from './mocks/environment.token.mock'; -import { ToastServiceMock } from './mocks/toast.service.mock'; import { TranslationServiceMock } from './mocks/translation.service.mock'; -import { provideActivatedRouteMock } from './providers/route-provider.mock'; -import { provideRouterMock } from './providers/router-provider.mock'; export function provideOSFCore() { return [ @@ -24,25 +20,3 @@ export function provideOSFCore() { export function provideOSFHttp() { return [provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()]; } - -export function provideOSFRouting() { - return [provideRouterMock(), provideActivatedRouteMock()]; -} - -export function provideOSFDialog() { - return [provideDynamicDialogRefMock()]; -} - -export function provideOSFToast() { - return [ToastServiceMock]; -} - -export function provideOSFTesting() { - return [ - ...provideOSFCore(), - ...provideOSFHttp(), - ...provideOSFRouting(), - ...provideOSFDialog(), - ...provideOSFToast(), - ]; -} diff --git a/src/testing/providers/component-provider.mock.ts b/src/testing/providers/component-provider.mock.ts index 92f036bf4..a63fe60ad 100644 --- a/src/testing/providers/component-provider.mock.ts +++ b/src/testing/providers/component-provider.mock.ts @@ -39,7 +39,6 @@ import { Component, EventEmitter, Input } from '@angular/core'; export function MockComponentWithSignal(selector: string, inputs: string[] = [], outputs: string[] = []): Type { @Component({ selector, - standalone: true, template: '', }) class MockComponent { From 70339af4c72b91f4a33f03526d8ab92c26c09a82 Mon Sep 17 00:00:00 2001 From: Vlad0n20 <137097005+Vlad0n20@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:05:19 +0200 Subject: [PATCH 15/27] [ENG-10042] fix(registry): moderators cannot approve registration updates (#887) - Ticket: [ENG-10042] - Feature flag: n/a ## Purpose Fix an issue where moderators could not approve registration updates because the mapper was selecting the wrong schema response (first one instead of the one with `pending_moderation` state), and the UI did not properly handle revisions awaiting admin approval. --- .../pages/registry-overview/registry-overview.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts index 69466b6e5..03978f4f0 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts @@ -20,7 +20,6 @@ import { import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; -import { CreateSchemaResponse } from '@osf/features/registries/store'; import { DataResourcesComponent } from '@osf/shared/components/data-resources/data-resources.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; @@ -46,6 +45,7 @@ import { RegistryRevisionsComponent } from '../../components/registry-revisions/ import { RegistryStatusesComponent } from '../../components/registry-statuses/registry-statuses.component'; import { WithdrawnMessageComponent } from '../../components/withdrawn-message/withdrawn-message.component'; import { + CreateSchemaResponse, GetRegistryById, GetRegistryReviewActions, GetRegistrySchemaResponses, From 5a3adbfb3708ddee368db80a09b6cd643e38a0c8 Mon Sep 17 00:00:00 2001 From: nsemets Date: Fri, 20 Feb 2026 18:14:16 +0200 Subject: [PATCH 16/27] [ENG-10255] Part 4: Add unit test coverage for registries (#892) - Ticket: [ENG-10255] - Feature flag: n/a ## Summary of Changes 1. Updated tests for registry. --- docs/testing.md | 975 ++++++++++++++---- jest.config.js | 8 +- .../add-resource-dialog.component.html | 3 +- .../add-resource-dialog.component.spec.ts | 224 ++-- .../add-resource-dialog.component.ts | 35 +- .../archiving-message.component.spec.ts | 98 +- .../archiving-message.component.ts | 2 +- .../edit-resource-dialog.component.spec.ts | 194 ++-- .../edit-resource-dialog.component.ts | 14 +- .../registration-links-card.component.html | 12 +- .../registration-links-card.component.spec.ts | 111 +- .../registration-links-card.component.ts | 8 +- ...tration-overview-toolbar.component.spec.ts | 164 +-- ...registration-overview-toolbar.component.ts | 70 +- ...egistration-withdraw-dialog.component.html | 2 +- ...stration-withdraw-dialog.component.spec.ts | 107 +- .../registration-withdraw-dialog.component.ts | 28 +- .../registry-blocks-section.component.spec.ts | 104 +- .../registry-make-decision.component.html | 8 +- .../registry-make-decision.component.spec.ts | 357 +++---- .../registry-make-decision.component.ts | 118 +-- ...gistry-overview-metadata.component.spec.ts | 216 ++-- .../registry-overview-metadata.component.ts | 21 +- .../registry-revisions.component.html | 8 +- .../registry-revisions.component.spec.ts | 418 +++----- .../registry-revisions.component.ts | 37 +- .../registry-statuses.component.html | 2 +- .../registry-statuses.component.spec.ts | 273 ++--- .../registry-statuses.component.ts | 19 +- .../resource-form.component.html | 4 +- .../resource-form.component.spec.ts | 125 +-- .../resource-form/resource-form.component.ts | 9 +- .../short-registration-info.component.spec.ts | 114 +- .../withdrawn-message.component.spec.ts | 108 +- .../withdrawn-message.component.ts | 2 +- ...stration-recent-activity.component.spec.ts | 187 +--- .../registry-components.component.spec.ts | 129 ++- .../registry-components.component.ts | 25 +- .../registry-links.component.html | 4 +- .../registry-links.component.spec.ts | 154 ++- .../registry-links.component.ts | 46 +- .../registry-overview.component.html | 4 +- .../registry-overview.component.spec.ts | 753 ++++---------- .../registry-overview.component.ts | 123 +-- .../registry-resources.component.html | 5 +- .../registry-resources.component.spec.ts | 323 ++++-- .../registry-resources.component.ts | 74 +- .../registry-wiki.component.html | 2 +- .../registry-wiki.component.spec.ts | 293 +++--- .../registry-wiki/registry-wiki.component.ts | 63 +- .../registry/registry.component.spec.ts | 197 +++- .../features/registry/registry.component.ts | 24 +- ...registration-blocks-data.component.spec.ts | 98 +- .../providers/analytics.service.mock.ts | 1 + src/testing/providers/route-provider.mock.ts | 22 +- src/testing/providers/store-provider.mock.ts | 19 + .../providers/view-only-link-helper.mock.ts | 17 + 57 files changed, 3188 insertions(+), 3373 deletions(-) create mode 100644 src/testing/providers/view-only-link-helper.mock.ts diff --git a/docs/testing.md b/docs/testing.md index 3de20e198..e05d231a4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,359 +1,908 @@ -# OSF Angular Testing Strategy - -## Index - -- [Overview](#overview) - - [Pro-tips](#pro-tips) -- [Best Practices](#best-practices) -- [Summary Table](#summary-table) -- [Test Coverage Enforcement (100%)](#test-coverage-enforcement-100) -- [Key Structure](#key-structure) -- [Testing Angular Services (with HTTP)](#testing-angular-services-with-http) -- [Testing Angular Components](#testing-angular-components) -- [Testing Angular Pipes](#testing-angular-pipes) -- [Testing Angular Directives](#testing-angular-directives) -- [Testing Angular NGXS](#testing-ngxs) +# OSF Angular Unit Testing Guide + +## Table of Contents + +1. [Test Stack](#1-test-stack) +2. [Project Testing Infrastructure](#2-project-testing-infrastructure) +3. [Test File Structure](#3-test-file-structure) +4. [TestBed Configuration](#4-testbed-configuration) +5. [Mocking Strategies](#5-mocking-strategies) +6. [Store Mocking](#6-store-mocking) +7. [Router & Route Mocking](#7-router--route-mocking) +8. [Service Mocking](#8-service-mocking) +9. [Signal-Based Testing](#9-signal-based-testing) +10. [Async Operations](#10-async-operations) +11. [Form Testing](#11-form-testing) +12. [Dialog Testing](#12-dialog-testing) +13. [Edge Cases](#13-edge-cases) +14. [Testing Angular Services (HTTP)](#14-testing-angular-services-http) +15. [Testing NGXS State](#15-testing-ngxs-state) +16. [Test Data](#16-test-data) +17. [Coverage Enforcement](#17-coverage-enforcement) +18. [Best Practices](#18-best-practices) +19. [Appendix: Assertion Patterns](#appendix-assertion-patterns) --- -## Overview +## 1. Test Stack -The OSF Angular project uses a modular and mock-driven testing strategy. A shared `testing/` folder provides reusable mocks, mock data, and testing module configuration to support consistent and maintainable unit tests across the codebase. +| Tool | Purpose | +| ------------------------- | --------------------------------------------------------------------------------------------- | +| **Jest** | Test runner & assertion library | +| **Angular TestBed** | Component / service compilation | +| **ng-mocks** | `MockComponents`, `MockModule`, `MockProvider` | +| **NGXS** | State management — mocked via `provideMockStore()` for components, real store for state tests | +| **RxJS** | Observable / Subject-based async testing | +| **HttpTestingController** | HTTP interception for service and state integration tests | +| **Custom utilities** | `src/testing/` — builders, factories, mock data | --- -### Pro-tips +## 2. Project Testing Infrastructure -**What to test** +### Directory: `src/testing/` -The OSF Angular testing strategy enforces 100% coverage while also serving as a guardrail for future engineers. Each test should highlight the most critical aspect of your code — what you’d want the next developer to understand before making changes. If a test fails during a refactor, it should clearly signal that a core feature was impacted, prompting them to investigate why and preserve the intended behavior. +``` +src/testing/ +├── osf.testing.provider.ts ← provideOSFCore(), provideOSFHttp() +├── osf.testing.module.ts ← OSFTestingModule (legacy — prefer providers) +├── providers/ ← Builder-pattern mocks for services +│ ├── store-provider.mock.ts +│ ├── route-provider.mock.ts +│ ├── router-provider.mock.ts +│ ├── toast-provider.mock.ts +│ ├── custom-confirmation-provider.mock.ts +│ ├── custom-dialog-provider.mock.ts +│ ├── component-provider.mock.ts +│ ├── loader-service.mock.ts +│ └── dialog-provider.mock.ts +├── mocks/ ← Mock domain models (89+ files) +│ ├── registries.mock.ts +│ ├── draft-registration.mock.ts +│ └── ... +└── data/ ← JSON API response fixtures + ├── dashboard/ + ├── addons/ + └── files/ +``` + +### `provideOSFCore()` — mandatory base provider + +Every component test must include `provideOSFCore()`. It configures animations, translations, and environment tokens. + +```typescript +export function provideOSFCore() { + return [ + provideNoopAnimations(), + importProvidersFrom(TranslateModule.forRoot()), + TranslationServiceMock, + EnvironmentTokenMock, + ]; +} +``` + +> **Never** import `OSFTestingModule` directly in new tests. It is retained for legacy compatibility only. Use `provideOSFCore()` instead. --- -**Test Data** +## 3. Test File Structure + +### Core rules + +- Prefer a single flat `describe` block per file to keep tests searchable and prevent state leakage. Use nested `describe` blocks when it significantly simplifies setup or groups logically distinct behaviors. +- For specs where all tests share a single configuration, use `beforeEach` with `TestBed.configureTestingModule` directly. Use a `setup()` helper when tests need different selector values, route configs, or other overrides. +- No `TestBed.resetTestingModule()` in `afterEach` — Angular auto-resets. +- Use actual interfaces/types for mock data instead of `any`. +- Co-locate unit tests with components using `*.spec.ts`. + +### Standard structure + +```typescript +describe('MyComponent', () => { + let component: MyComponent; + let fixture: ComponentFixture; + let store: Store; + + beforeEach(() => { + TestBed.configureTestingModule({ ... }); + store = TestBed.inject(Store); + fixture = TestBed.createComponent(MyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); +``` + +### `setup()` helper — parameterised tests + +Use when tests need different selector values or route configs. Avoids duplicating `TestBed` configuration across tests. -The OSF Angular Test Data module provides a centralized and consistent source of data across all unit tests. It is intended solely for use within unit tests. By standardizing test data, any changes to underlying data models will produce cascading failures, which help expose the full scope of a refactor. This is preferable to isolated or hardcoded test values, which can lead to false positives and missed regressions. +Extend `BaseSetupOverrides` from `@testing/providers/store-provider.mock` when the spec only needs standard route/selector overrides. Add component-specific fields as needed. -The strategy for structuring test data follows two principles: +Use `mergeSignalOverrides` from `@testing/providers/store-provider.mock` to apply selector overrides on top of default signal values. -1. Include enough data to cover all relevant permutations required by the test suite. -2. Ensure the data reflects all possible states (stati) of the model. +Use `withNoParent()` on `ActivatedRouteMockBuilder` when testing components that guard against a missing parent route. -**Test Scope** +```typescript +import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; -The OSF Angular project defines a `@testing` scope that can be used for importing all testing-related modules. +interface SetupOverrides extends BaseSetupOverrides { + routerUrl?: string; +} + +function setup(overrides: SetupOverrides = {}) { + const routeBuilder = ActivatedRouteMockBuilder.create().withParams(overrides.routeParams ?? { id: 'draft-1' }); + if (overrides.hasParent === false) routeBuilder.withNoParent(); + const mockRoute = routeBuilder.build(); + + const mockRouter = RouterMockBuilder.create() + .withUrl(overrides.routerUrl ?? '/registries/drafts/reg-1/1') + .build(); + + const defaultSignals = [{ selector: MySelectors.getData, value: mockData }]; + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [MyComponent], + providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(Router, mockRouter), + provideMockStore({ signals }), + ], + }); + + const store = TestBed.inject(Store); + const fixture = TestBed.createComponent(MyComponent); + return { fixture, component: fixture.componentInstance, store }; +} + +// Usage +it('should handle missing data', () => { + const { component } = setup({ + selectorOverrides: [{ selector: MySelectors.getData, value: null }], + }); + expect(component.hasData()).toBe(false); +}); + +it('should not dispatch when parent route is absent', () => { + const { store } = setup({ hasParent: false }); + expect(store.dispatch).not.toHaveBeenCalled(); +}); +``` --- -## Best Practices +## 4. TestBed Configuration + +### Standalone components (standard) + +```typescript +TestBed.configureTestingModule({ + imports: [ + ComponentUnderTest, + ...MockComponents(ChildA, ChildB), + MockModule(PrimeNGModule), + ], + providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(Router, mockRouter), + MockProvider(ToastService, ToastServiceMock.simple()), + provideMockStore({ signals: [...] }), + ], +}); +``` -- Always import `OsfTestingModule` or `OsfTestingStoreModule` to minimize boilerplate and get consistent mock behavior. -- Use mocks and mock-data from `testing/` to avoid repeating test setup. -- Avoid real HTTP, translation, or store dependencies in unit tests by default. -- Co-locate unit tests with components using `*.spec.ts`. +### Components with signal-input children + +Use `overrideComponent` when a child uses Angular signal viewChild and `MockComponents` cannot stub it correctly. + +```typescript +TestBed.configureTestingModule({ ... }) + .overrideComponent(FilesControlComponent, { + remove: { imports: [FilesTreeComponent] }, + add: { + imports: [ + MockComponentWithSignal('osf-files-tree', [ + 'files', + 'selectionMode', + 'totalCount', + 'storage', + 'currentFolder', + 'isLoading', + 'scrollHeight', + 'viewOnly', + 'resourceId', + 'provider', + 'selectedFiles', + ]), + ], + }, + }); +``` --- -## Summary Table +## 5. Mocking Strategies + +### Priority order -| Location | Purpose | -| ----------------------- | -------------------------------------- | -| `osf.testing.module.ts` | Unified test module for shared imports | -| `src/mocks/*.mock.ts` | Mock services and tokens | -| `src/data/*.data.ts` | Static mock data for test cases | +Always check `@testing/` before writing inline mocks. Builders and factories almost certainly exist. + +1. Use existing builders/factories from `@testing/providers/` +2. Use `MockProvider` with an explicit mock object +3. Use `MockComponents` / `MockModule` from ng-mocks +4. Use `MockComponentWithSignal` for signal-input children +5. Inline `jest.fn()` mocks as a last resort + +### Quick reference + +| Need | Use | +| -------------------------- | ------------------------------------------------------- | +| Store selectors / dispatch | `provideMockStore()` | +| Router | `RouterMockBuilder` | +| ActivatedRoute | `ActivatedRouteMockBuilder` | +| ToastService | `ToastServiceMock.simple()` | +| CustomConfirmationService | `CustomConfirmationServiceMock.simple()` | +| CustomDialogService | `CustomDialogServiceMockBuilder` | +| LoaderService | `new LoaderServiceMock()` | +| Child components | `MockComponents(...)` or `MockComponentWithSignal(...)` | +| PrimeNG modules | `MockModule(...)` | + +> **Rule:** Bare `MockProvider(Service)` creates ng-mocks stubs, not `jest.fn()`. When you need `.mockImplementation`, `.mockClear`, or assertion checking, always pass an explicit mock as the second argument. --- -## Test Coverage Enforcement (100%) +## 6. Store Mocking + +### `provideMockStore` configuration options + +| Config key | Maps to | Use case | +| ----------- | ------------------------------------- | ------------------------------------ | +| `signals` | `store.selectSignal()` | Signal-based selectors (most common) | +| `selectors` | `store.select()` / `selectSnapshot()` | Observable-based selectors | +| `actions` | `store.dispatch()` return value | When component reads dispatch result | + +```typescript +provideMockStore({ + signals: [ + { selector: RegistriesSelectors.getDraftRegistration, value: mockDraft }, + { selector: RegistriesSelectors.getStepsState, value: stepsStateSignal }, + ], + actions: [ + { action: new CreateDraft({ ... }), value: { id: 'new-draft' } }, + ], +}) +``` + +### `mergeSignalOverrides` — applying selector overrides in `setup()` -This project **strictly enforces 100% test coverage** through the following mechanisms: +Use `mergeSignalOverrides(defaults, overrides)` from `@testing/providers/store-provider.mock` instead of inlining the merge logic. It replaces matching selectors and preserves the rest. -### Husky Pre-Push Hook +```typescript +import { mergeSignalOverrides } from '@testing/providers/store-provider.mock'; -Before pushing any code, Husky runs a **pre-push hook** that executes: +const defaultSignals = [ + { selector: MySelectors.getData, value: [] }, + { selector: MySelectors.isLoading, value: false }, +]; +const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); +``` + +### Dispatch assertions + +```typescript +expect(store.dispatch).toHaveBeenCalledWith(new MyAction('id')); +expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(MyAction)); -```bash -npm run test:coverage +// Filter by action type across multiple dispatches +const calls = (store.dispatch as jest.Mock).mock.calls.filter(([a]: [any]) => a instanceof GetProjects); +expect(calls.length).toBe(1); ``` -This command: +### Clearing init dispatches -- Runs the full test suite with `--coverage`. -- Fails the push if **coverage drops below 100%**. -- Ensures developers never bypass test coverage enforcement locally. +When `ngOnInit` dispatches and you need isolated per-test assertions: -> Pro Tip: Use `npm run test:watch` during development to maintain coverage incrementally. +```typescript +(store.dispatch as jest.Mock).mockClear(); +component.doSomething(); +expect(store.dispatch).toHaveBeenCalledWith(new SpecificAction()); +``` --- -### GitHub Actions CI +## 7. Router & Route Mocking -Every pull request and push runs GitHub Actions that: +### ActivatedRoute -- Run `npm run test:coverage`. -- Verify test suite passes with **100% code coverage**. -- Fail the build if even **1 uncovered branch/line/function** exists. +```typescript +const mockRoute = ActivatedRouteMockBuilder.create() + .withParams({ id: 'draft-1' }) + .withQueryParams({ projectId: 'proj-1' }) + .withData({ feature: 'registries' }) + .build(); -This guarantees **test integrity in CI** and **prevents regressions**. +// Nested child routes +const mockRoute = ActivatedRouteMockBuilder.create() + .withParams({ id: 'reg-1' }) + .withFirstChild((child) => child.withParams({ step: '2' })) + .build(); ---- +// No parent route (for testing components that guard against missing parent) +const mockRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'reg-1' }).withNoParent().build(); +``` -### Coverage Expectations +### Router -| File Type | Coverage Requirement | -| ----------- | ------------------------------------------ | -| `*.ts` | 100% line & branch | -| `*.spec.ts` | Required per file | -| Services | Must mock HTTP via `HttpTestingController` | -| Components | DOM + Input + Output event coverage | -| Pipes/Utils | All edge cases tested | +```typescript +const mockRouter = RouterMockBuilder.create().withUrl('/registries/drafts/reg-1/metadata').build(); + +expect(mockRouter.navigate).toHaveBeenCalledWith(['../1'], expect.objectContaining({ relativeTo: expect.anything() })); +expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/registries/prov-1/new'); +``` --- -### Summary +## 8. Service Mocking -- **Zero exceptions** for test coverage. -- **Push blocked** without passing 100% tests. -- GitHub CI double-checks every PR. +### Simple factories + +```typescript +const toastService = ToastServiceMock.simple(); +const confirmationService = CustomConfirmationServiceMock.simple(); +// Returns plain objects with jest.fn() methods — safe to assert on directly +``` + +### Builder pattern + +```typescript +const mockDialog = CustomDialogServiceMockBuilder.create() + .withOpen( + jest.fn().mockReturnValue({ + onClose: dialogClose$.pipe(), + close: jest.fn(), + }) + ) + .build(); +``` + +### Inline mock (no builder exists) + +```typescript +const mockFilesService = { + uploadFile: jest.fn(), + getFileGuid: jest.fn(), +}; +MockProvider(FilesService, mockFilesService); +``` --- -## Key Structure +## 9. Signal-Based Testing -### `src/testing/osf.testing.module.ts` +### `WritableSignal` for dynamic state -This module centralizes commonly used providers, declarations, and test utilities. It's intended to be imported into any `*.spec.ts` test file to avoid repetitive boilerplate. +Pass a `WritableSignal` as the selector value to change state mid-test. The mock store detects `isSignal(value)` and returns it as-is, so updates propagate automatically. -Example usage: +```typescript +let stepsStateSignal: WritableSignal<{ invalid: boolean }[]>; -```ts -import { TestBed } from '@angular/core/testing'; -import { OsfTestingModule } from '@testing/osf.testing.module'; +beforeEach(() => { + stepsStateSignal = signal([{ invalid: true }]); + provideMockStore({ + signals: [{ selector: RegistriesSelectors.getStepsState, value: stepsStateSignal }], + }); +}); -beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [OsfTestingModule], - }).compileComponents(); +it('should react to signal changes', () => { + expect(component.isDraftInvalid()).toBe(true); + stepsStateSignal.set([{ invalid: false }]); + expect(component.isDraftInvalid()).toBe(false); }); ``` -### OSFTestingModule +### Setting signal inputs + +```typescript +fixture.componentRef.setInput('attachedFiles', []); +fixture.componentRef.setInput('projectId', 'project-1'); +fixture.detectChanges(); -**Imports:** +// Never use direct property assignment for signal inputs +``` -- `NoopAnimationsModule` – disables Angular animations for clean, predictable unit tests. -- `BrowserModule` – required for bootstrapping Angular features. -- `CommonModule` – provides core Angular directives (e.g., `ngIf`, `ngFor`). -- `TranslateModule.forRoot()` – sets up the translation layer for template-based testing with `@ngx-translate`. +--- -**Providers:** +## 10. Async Operations -- `provideNoopAnimations()` – disables animation via the new standalone provider API. -- `provideRouter([])` – injects an empty router config for component-level testing. -- `provideHttpClient(withInterceptorsFromDi())` – ensures DI-compatible HTTP interceptors are respected in tests. -- `provideHttpClientTesting()` – injects `HttpTestingController` for mocking HTTP requests in unit tests. -- `TranslationServiceMock` – mocks i18n service methods. -- `EnvironmentTokenMock` – mocks environment config values. +### `fakeAsync` + `tick` for debounced operations ---- +```typescript +it('should dispatch after debounce', fakeAsync(() => { + (store.dispatch as jest.Mock).mockClear(); + component.onProjectFilter('abc'); + tick(300); + expect(store.dispatch).toHaveBeenCalledWith(new GetProjects('user-1', 'abc')); +})); -### OSFTestingStoreModule +// Deduplication — only the last value dispatches +it('should debounce rapid calls', fakeAsync(() => { + (store.dispatch as jest.Mock).mockClear(); + component.onProjectFilter('a'); + component.onProjectFilter('ab'); + component.onProjectFilter('abc'); + tick(300); + const calls = (store.dispatch as jest.Mock).mock.calls.filter(([a]: [any]) => a instanceof GetProjects); + expect(calls.length).toBe(1); +})); +``` + +### `done` callback for output emissions + +```typescript +it('should emit attachFile', (done) => { + component.attachFile.subscribe((f) => { + expect(f).toEqual({ id: 'file-1' }); + done(); + }); + component.selectFile({ id: 'file-1' } as FileModel); +}); +``` -**Imports:** +--- -- `OSFTestingModule` – reuses core mocks and modules. +## 11. Form Testing -**Providers:** +### Validation and submit -- `StoreMock` – mocks NgRx Store for selector and dispatch testing. -- `ToastServiceMock` – injects a mock version of the UI toast service. +```typescript +it('should be invalid when title is empty', () => { + component.metadataForm.patchValue({ title: '' }); + expect(component.metadataForm.get('title')?.valid).toBe(false); +}); -### Testing Mocks +it('should trim values on submit', () => { + component.metadataForm.patchValue({ + title: ' Padded Title ', + description: ' Padded Desc ', + }); + (store.dispatch as jest.Mock).mockClear(); + component.submitMetadata(); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateDraft('draft-1', expect.objectContaining({ title: 'Padded Title' })) + ); +}); +``` -The `src/testing/mocks/` directory provides common service and token mocks to isolate unit tests from real implementations. +### Validator toggling & touched state -**examples** +```typescript +it('should toggle validator', () => { + component.toggleFromProject(); + expect(component.draftForm.get('project')?.validator).toBeTruthy(); + component.toggleFromProject(); + expect(component.draftForm.get('project')?.validator).toBeNull(); +}); -- `environment.token.mock.ts` – Mocks environment tokens like base API URLs. -- `store.mock.ts` – NGXS or other store-related mocks. -- `translation.service.mock.ts` – Prevents needing actual i18n setup during testing. -- `toast.service.mock.ts` – Mocks user feedback services to track invocations without UI. +it('should mark form touched on init when invalid', () => { + expect(component.metadataForm.touched).toBe(true); +}); +``` --- -### Test Data +## 12. Dialog Testing -The `src/testing/data/` directory includes fake/mock data used by tests to simulate external API responses or internal state. +### Subject-based `onClose` -The OSF Angular Test Data module provides a centralized and consistent source of data across all unit tests. It is intended solely for use within unit tests. By standardizing test data, any changes to underlying data models will produce cascading failures, which help expose the full scope of a refactor. This is preferable to isolated or hardcoded test values, which can lead to false positives and missed regressions. +Always use a real `Subject` for `onClose` — `MockProvider` cannot auto-generate reactive streams. Use `provideDynamicDialogRefMock()` where applicable. -The strategy for structuring test data follows two principles: +```typescript +const dialogClose$ = new Subject(); +const mockDialog = CustomDialogServiceMockBuilder.create() + .withOpen( + jest.fn().mockReturnValue({ + onClose: dialogClose$.pipe(), + close: jest.fn(), + }) + ) + .build(); -1. Include enough data to cover all relevant permutations required by the test suite. -2. Ensure the data reflects all possible states (stati) of the model. +it('should navigate on confirm', () => { + component.openConfirmDialog(); + dialogClose$.next(true); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/new-reg-1/overview']); +}); -**examples** +it('should not navigate on cancel', () => { + component.openConfirmDialog(); + dialogClose$.next(false); + expect(mockRouter.navigate).not.toHaveBeenCalled(); +}); +``` + +### Chained dialogs + +```typescript +it('should pass data between dialogs', () => { + const selectClose$ = new Subject(); + const confirmClose$ = new Subject(); + let callCount = 0; + + (dialog.open as jest.Mock).mockImplementation(() => { + callCount++; + const subj = callCount === 1 ? selectClose$ : confirmClose$; + return { onClose: subj.pipe(), close: jest.fn() }; + }); + + component.openSelectComponentsDialog(); + selectClose$.next(['comp-1']); -- `addons.authorized-storage.data.ts` -- `addons.external-storage.data.ts` -- `addons.configured.data.ts` -- `addons.operation-invocation.data.ts` + expect(dialog.open).toHaveBeenCalledTimes(2); + const secondArgs = (dialog.open as jest.Mock).mock.calls[1]; + expect(secondArgs[1].data.components).toEqual(['comp-1']); +}); +``` + +### Confirmation service (auto-confirm pattern) + +```typescript +it('should dispatch on confirm', () => { + mockConfirmation.confirmDelete.mockImplementation(({ onConfirm }: any) => onConfirm()); + (store.dispatch as jest.Mock).mockClear(); + component.deleteDraft(); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteDraft('draft-1')); +}); +``` --- -## Testing Angular Services (with HTTP) +## 13. Edge Cases -All OSF Angular services that make HTTP requests must be tested using `HttpClientTestingModule` and `HttpTestingController`. This testing style verifies both the API call itself and the logic that maps the response into application data. +### `ngOnDestroy` — conditional cleanup -When using HttpTestingController to flush HTTP requests in tests, only use data from the @testing/data mocks to ensure consistency and full test coverage. +Components that auto-save on destroy must skip saves when the resource was already deleted. Test both paths. -Any error handling will also need to be tested. +```typescript +it('should skip updates on destroy when draft was deleted', () => { + (store.dispatch as jest.Mock).mockClear(); + component.isDraftDeleted = true; + component.ngOnDestroy(); + expect(store.dispatch).not.toHaveBeenCalled(); +}); -### Setup +it('should dispatch update on destroy when fields changed', () => { + component.metadataForm.patchValue({ title: 'Changed Title' }); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateDraft('draft-1', expect.objectContaining({ title: 'Changed Title' })) + ); +}); -```ts -import { HttpTestingController } from '@angular/common/http/testing'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +it('should not dispatch update on destroy when fields are unchanged', () => { + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdateDraft)); +}); +``` -let service: YourService; +### Null / undefined selector values -beforeEach(() => { - TestBed.configureTestingModule({ - imports: [OSFTestingModule], - providers: [YourService], +```typescript +it('should handle null draft', () => { + const { component } = setup({ + selectorOverrides: [{ selector: Selectors.getDraft, value: null }], }); + expect(component).toBeTruthy(); +}); +``` - service = TestBed.inject(YourService); +### Empty arrays vs populated arrays + +```typescript +it('should mark invalid when required field has empty array', () => { + const { component } = setup({ + selectorOverrides: [{ selector: Selectors.getStepsData, value: { field1: [] } }], + }); + expect(component.steps()[1].invalid).toBe(true); +}); + +it('should not mark invalid with non-empty array', () => { + const { component } = setup({ + selectorOverrides: [{ selector: Selectors.getStepsData, value: { field1: ['item'] } }], + }); + expect(component.steps()[1].invalid).toBe(false); }); ``` -### Example Test +### Missing links / properties -```ts -it('should call correct endpoint and return expected data', inject( - [HttpTestingController], - (httpMock: HttpTestingController) => { - service.getSomething().subscribe((data) => { - expect(data).toEqual(mockData); - }); +```typescript +it('should not upload when no upload link', () => { + currentFolderSignal.set({ links: {} } as FileFolderModel); + component.uploadFiles(file); + expect(mockFilesService.uploadFile).not.toHaveBeenCalled(); +}); +``` - const req = httpMock.expectOne('/api/endpoint'); - expect(req.request.method).toBe('GET'); - req.flush(getMockDataFromTestingData()); +### File size limits - httpMock.verify(); // Verify no outstanding HTTP calls - } -)); +```typescript +it('should warn on oversized file', () => { + const oversizedFile = new File([''], 'big.bin'); + Object.defineProperty(oversizedFile, 'size', { value: FILE_SIZE_LIMIT }); + component.onFileSelected({ target: { files: [oversizedFile] } } as unknown as Event); + expect(toastService.showWarn).toHaveBeenCalledWith('shared.files.limitText'); +}); ``` -### Key Rules +### Deduplication -- Use `OSFTestingModule` to isolate the service -- Inject and use `HttpTestingController` -- Always call `httpMock.expectOne()` to verify the URL and method -- Always call `req.flush()` to simulate the backend response -- Add `httpMock.verify()` in each `it` to catch unflushed requests +```typescript +it('should deduplicate file selection', () => { + const file = { id: 'file-1' } as FileModel; + component.onFileTreeSelected(file); + component.onFileTreeSelected(file); + expect(component.filesSelection).toEqual([file]); +}); +``` + +### Conditional dispatch based on state + +```typescript +it('should not dispatch when submitting', () => { + const { store } = setup({ + selectorOverrides: [ + { selector: Selectors.isDraftSubmitting, value: true }, + { selector: Selectors.getDraft, value: { ...DEFAULT_DRAFT, hasProject: true } }, + ], + }); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchProjectChildren)); +}); +``` --- -## Testing Angular Components +## 14. Testing Angular Services (HTTP) -- coming soon +All services that make HTTP requests must be tested using `HttpClientTestingModule` and `HttpTestingController`. Only use data from `@testing/data` mocks when flushing requests — never hardcode response values inline. ---- +### Setup -## Testing Angular Pipes +```typescript +import { HttpTestingController } from '@angular/common/http/testing'; +import { provideOSFCore, provideOSFHttp } from '@testing/osf.testing.provider'; -- coming soon +let service: YourService; ---- +beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [provideOSFCore(), provideOSFHttp(), YourService], + }); + service = TestBed.inject(YourService); +}); +``` -## Testing Angular Directives +### Example test -- coming soon +```typescript +it('should call correct endpoint and return expected data', () => { + const httpMock = TestBed.inject(HttpTestingController); ---- + service.getSomething().subscribe((data) => { + expect(data).toEqual(mockData); + }); + + const req = httpMock.expectOne('/api/endpoint'); + expect(req.request.method).toBe('GET'); + req.flush(getMockDataFromTestingData()); -## NGXS State Testing Strategy + httpMock.verify(); +}); +``` -The OSF Angular strategy for NGXS state testing is to create **small integration test scenarios**. This is a deliberate departure from traditional **black box isolated** testing. The rationale is: +### Key rules -1. **NGXS actions** tested in isolation are difficult to mock and result in garbage-in/garbage-out tests. -2. **NGXS selectors** tested in isolation are easy to mock but also lead to garbage-in/garbage-out outcomes. -3. **NGXS states** tested in isolation are easy to invoke but provide no meaningful validation. -4. **Mocking service calls** during state testing introduces false positives, since the mocked service responses may not reflect actual backend behavior. +- Use `provideOSFCore() + provideOSFHttp()` to isolate the service +- Always call `httpMock.expectOne()` to verify the URL and method +- Always call `req.flush()` with data from `@testing/data` — never hardcode responses inline +- Add `httpMock.verify()` at the end of each test to catch unflushed requests +- Error handling paths must also be tested -This approach favors realism and accuracy over artificial test isolation. +--- -### Test Outline Strategy +## 15. Testing NGXS State -1. **Dispatch the primary action** – Kick off the state logic under test. -2. **Dispatch any dependent actions** – Include any secondary actions that rely on the primary action's outcome. -3. **Verify the loading selector is `true`** – Ensure the loading state is activated during the async flow. -4. **Verify the service call using `HttpTestingController` and `@testing/data` mocks** – Confirm that the correct HTTP request is made and flushed with known mock data. -5. **Verify the loading selector is `false`** – Ensure the loading state deactivates after the response is handled. -6. **Verify the primary data selector** – Check that the core selector related to the dispatched action returns the expected state. -7. **Verify any additional selectors** – Assert the output of other derived selectors relevant to the action. -8. **Validate the test with `httpMock.verify()`** – Confirm that all HTTP requests were flushed and none remain unhandled: +The OSF Angular strategy for NGXS state testing is to create **small integration test scenarios** rather than isolated unit tests. This is a deliberate design decision. -```ts -expect(httpMock.verify).toBeTruthy(); -``` +### Why integration testing for NGXS? -### Example +- Actions tested in isolation are hard to mock and produce garbage-in/garbage-out tests +- Selectors tested in isolation are easy to mock but equally produce false positives +- States tested in isolation are easy to invoke but provide no meaningful validation +- Mocking service calls during state tests introduces false positives — mocked responses may not reflect actual backend behaviour -This is an example of an NGXS action test that involves both a **primary action** and a **dependent action**. The dependency must be dispatched first to ensure the test environment mimics the actual runtime behavior. This pattern helps validate not only the action effects but also the full selector state after updates. All HTTP requests are flushed using the centralized `@testing/data` mocks. +### Test outline — required steps -```ts -it('should test action, state and selectors', inject([HttpTestingController], (httpMock: HttpTestingController) => { +1. **Dispatch the primary action** — kick off the state logic under test +2. **Dispatch any dependent actions** — include secondary actions that rely on the primary action's outcome +3. **Verify the loading selector is `true`** — ensure loading state activates during the async flow +4. **Flush HTTP requests with `@testing/data` mocks** — confirm correct requests are made and flushed with known data +5. **Verify the loading selector is `false`** — ensure loading deactivates after the response is handled +6. **Verify the primary data selector** — check the core selector returns expected state +7. **Verify additional selectors** — assert derived selectors relevant to the action +8. **Call `httpMock.verify()`** — confirm no HTTP requests remain unhandled + +### Example + +```typescript +it('should test action, state and selectors', () => { + const httpMock = TestBed.inject(HttpTestingController); let result: any[] = []; - // Dependency Action + + // 1. Dispatch dependent action first store.dispatch(new GetAuthorizedStorageAddons('reference-id')).subscribe(); - // Primary Action + // 2. Dispatch primary action store.dispatch(new GetAuthorizedStorageOauthToken('account-id')).subscribe(() => { result = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddons); }); - // Loading selector is true + // 3. Loading selector is true const loading = store.selectSignal(AddonsSelectors.getAuthorizedStorageAddonsLoading); expect(loading()).toBeTruthy(); - // Http request for service for dependency action - let request = httpMock.expectOne('api/path/dependency/action'); - expect(request.request.method).toBe('GET'); - // @testing/data response mock - request.flush(getAddonsAuthorizedStorageData()); + // 4a. Flush dependent action HTTP request + let req = httpMock.expectOne('api/path/dependency/action'); + expect(req.request.method).toBe('GET'); + req.flush(getAddonsAuthorizedStorageData()); - // Http request for service for primary action - let request = httpMock.expectOne('api/path/primary/action'); - expect(request.request.method).toBe('PATCH'); - // @testing/data response mock with updates + // 4b. Flush primary action HTTP request + req = httpMock.expectOne('api/path/primary/action'); + expect(req.request.method).toBe('PATCH'); const addonWithToken = getAddonsAuthorizedStorageData(1); addonWithToken.data.attributes.oauth_token = 'ya2.34234324534'; - request.flush(addonWithToken); + req.flush(addonWithToken); - // Full testing of the dependency selector - expect(result[1]).toEqual( - Object({ - accountOwnerId: '0b441148-83e5-4f7f-b302-b07b528b160b', - }) - ); + // 5. Loading selector is false + expect(loading()).toBeFalsy(); - // Full testing of the primary selector - let oauthToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[0].id)); + // 6. Primary selector — verify only the targeted record was updated + const oauthToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[0].id)); expect(oauthToken).toBe('ya29.A0AS3H6NzDCKgrUx'); - // Verify only the requested `account-id` was updated - oauthToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[1].id)); - expect(oauthToken).toBe(result[1].oauthToken); + // 7. Other selector — verify untargeted record is unchanged + const otherToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[1].id)); + expect(otherToken).toBe(result[1].oauthToken); - // Loading selector is false - expect(loading()).toBeFalsy(); - - // httpMock.verify to ensure no other api calls are called. - expect(httpMock.verify).toBeTruthy(); -})); + // 8. No outstanding requests + httpMock.verify(); +}); ``` --- + +## 16. Test Data + +Test data lives in two directories under `src/testing/`. Always use these — never hardcode response values inline in tests. + +### `testing/mocks/` — domain model mocks (89+ files) + +Pre-built mock objects for domain models used directly in component tests. Imported via `@testing/mocks/*`. + +| File | Purpose | +| ---------------------------- | ---------------------------------------------- | +| `registries.mock.ts` | `MOCK_DRAFT_REGISTRATION`, `MOCK_PAGES_SCHEMA` | +| `draft-registration.mock.ts` | `MOCK_DRAFT_REGISTRATION` with full shape | +| `schema-response.mock.ts` | Schema response fixtures | +| `contributors.mock.ts` | Contributor model mocks | +| `project.mock.ts` | Project model mocks | + +### `testing/data/` — JSON API response fixtures + +Centralised raw JSON API responses used for HTTP flush in service and state integration tests. Imported via `@testing/data/*`. + +| File | Purpose | +| ------------------------------------- | --------------------------------- | +| `addons.authorized-storage.data.ts` | Authorised storage addon fixtures | +| `addons.external-storage.data.ts` | External storage addon fixtures | +| `addons.configured.data.ts` | Configured addon state fixtures | +| `addons.operation-invocation.data.ts` | Operation invocation fixtures | + +### Why centralised test data matters + +- Any change to an underlying data model produces cascading test failures, exposing the full scope of a refactor +- Hardcoded inline values lead to false positives and missed regressions +- Consistent data across tests makes selector and state assertions directly comparable + +### Data structure principles + +1. Include enough data to cover all relevant permutations required by the test suite +2. Ensure data reflects all possible states of the model + +--- + +## 17. Coverage Enforcement + +This project strictly enforces 90%+ test coverage through GitHub Actions CI. + +### Coverage requirements + +| File type | Requirement | Notes | +| ------------- | ------------------ | ------------------------------------------ | +| `*.ts` | 90%+ line & branch | Zero exceptions | +| Services | 90%+ | Must mock HTTP via `HttpTestingController` | +| Components | 90%+ | DOM + Input + Output event coverage | +| Pipes / utils | 90%+ | All edge cases tested | +| NGXS state | 90%+ | Integration test approach required | + +### Enforcement pipeline + +- **GitHub Actions CI:** runs on every PR and push — build fails if a single uncovered branch, line, or function exists + +> **Tip:** Use `npm run test:watch` during development to maintain coverage incrementally rather than discovering gaps at push time. + +--- + +## 18. Best Practices + +1. **Always use `provideOSFCore()`** — never import `OSFTestingModule` directly in new tests. +2. **Always use `provideMockStore()`** — never mock `component.actions` via `Object.defineProperty`. +3. **Always pass explicit mocks to `MockProvider`** when you need `jest.fn()` assertions. Bare `MockProvider(Service)` creates ng-mocks stubs. +4. **Check `@testing/` before creating inline mocks** — builders and factories almost certainly exist. +5. **Prefer a single flat `describe` block** per file to keep tests searchable and prevent state leakage. Use nested `describe` blocks when it significantly simplifies setup or groups logically distinct behaviors. No `afterEach`. +6. **No redundant tests** — merge tests that cover the same code path. +7. **Use `(store.dispatch as jest.Mock).mockClear()`** when `ngOnInit` dispatches and you need isolated per-test assertions. +8. **Use `WritableSignal` for dynamic state** — pass `signal()` values to `provideMockStore` when tests need to mutate state mid-test. +9. **Use `Subject` for dialog `onClose`** — gives explicit control over dialog result timing. Use `provideDynamicDialogRefMock()` where applicable. +10. **Use `fakeAsync` + `tick`** for debounced operations — specify the exact debounce duration. +11. **Use `fixture.componentRef.setInput()`** for signal inputs — never direct property assignment. +12. **Use `ngMocks.faster()`** when all tests in a file share identical `TestBed` config — reuses the compiled module for speed. Do not use if any test requires a different config: shared state will cause subtle test pollution. +13. **Use typed mock interfaces** (`ToastServiceMockType`, `RouterMockType`, etc.) — avoid `any`. +14. **Test both positive and negative paths** — confirm an action fires AND confirm it does not fire when conditions are not met. +15. **Only use `@testing/data` fixtures in HTTP flushes** — never hardcode response values inline in service or state tests. +16. **Each test should highlight the most critical aspect of the code** — if a test fails during a refactor, it should clearly signal that a core feature was impacted. + +--- + +## Appendix: Assertion Patterns + +### Action dispatch + +```typescript +expect(store.dispatch).toHaveBeenCalledWith(new MyAction('id')); +expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(MyAction)); +expect(store.dispatch).toHaveBeenCalledWith(new UpdateDraft('draft-1', expect.objectContaining({ title: 'Changed' }))); +``` + +### Router navigation + +```typescript +expect(mockRouter.navigate).toHaveBeenCalledWith(['../1'], expect.objectContaining({ relativeTo: expect.anything() })); +expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/target'); +``` + +### Dialog open calls + +```typescript +expect(mockDialog.open).toHaveBeenCalled(); +const callArgs = (mockDialog.open as jest.Mock).mock.calls[0]; +expect(callArgs[1].header).toBe('expected.title'); +expect(callArgs[1].data.draftId).toBe('draft-1'); +``` + +### Filtering dispatch calls by action type + +```typescript +const calls = (store.dispatch as jest.Mock).mock.calls.filter(([a]: [any]) => a instanceof GetProjects); +expect(calls.length).toBe(1); +expect(calls[0][0]).toEqual(new GetProjects('user-1', 'abc')); +``` diff --git a/jest.config.js b/jest.config.js index a59db139c..ff42f9ea5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -54,10 +54,10 @@ module.exports = { extensionsToTreatAsEsm: ['.ts'], coverageThreshold: { global: { - branches: 34.8, - functions: 38.0, - lines: 65.5, - statements: 66.0, + branches: 39.5, + functions: 41.1, + lines: 68.0, + statements: 68.4, }, }, watchPathIgnorePatterns: [ diff --git a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html index 9f8a88dc4..5ca0e95f2 100644 --- a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html +++ b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html @@ -7,13 +7,12 @@ @let resourceType = currentResource()?.type; @let iconName = resourceType === 'analytic_code' ? 'code' : resourceType; @let icon = `custom-icon-${iconName} icon-resource-size`; - @let resourceName = resourceType === RegistryResourceType.Code ? 'Analytic Code' : resourceType;
-

{{ resourceName }}

+

{{ resourceTypeTranslationKey() | translate }}

{{ doiLink() }} diff --git a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.spec.ts b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.spec.ts index b934deae5..8ad13dace 100644 --- a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.spec.ts +++ b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.spec.ts @@ -1,89 +1,92 @@ import { Store } from '@ngxs/store'; -import { MockComponent, MockProvider } from 'ng-mocks'; +import { MockComponents, MockProvider } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; +import { RegistryResourceType } from '@osf/shared/enums/registry-resource.enum'; +import { RegistryResource } from '../../models'; import { RegistryResourcesSelectors } from '../../store/registry-resources'; import { ResourceFormComponent } from '../resource-form/resource-form.component'; import { AddResourceDialogComponent } from './add-resource-dialog.component'; -import { DynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; -import { TranslateServiceMock } from '@testing/mocks/translate.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; + +const MOCK_RESOURCE: RegistryResource = { + id: 'res-1', + description: 'Test', + finalized: false, + type: RegistryResourceType.Data, + pid: '10.1234/test', +}; + +interface SetupOverrides { + registryId?: string; + selectorOverrides?: SignalOverride[]; +} + +function setup(overrides: SetupOverrides = {}) { + const mockDialogConfig = { data: { id: overrides.registryId ?? 'registry-123' } }; + + const defaultSignals = [ + { selector: RegistryResourcesSelectors.getCurrentResource, value: null }, + { selector: RegistryResourcesSelectors.isCurrentResourceLoading, value: false }, + ]; + + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [ + AddResourceDialogComponent, + ...MockComponents(LoadingSpinnerComponent, ResourceFormComponent, IconComponent), + ], + providers: [ + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, mockDialogConfig), + provideMockStore({ signals }), + ], + }); + + const store = TestBed.inject(Store); + const dialogRef = TestBed.inject(DynamicDialogRef); + const fixture = TestBed.createComponent(AddResourceDialogComponent); + const component = fixture.componentInstance; + fixture.detectChanges(); + + return { fixture, component, store, dialogRef }; +} describe('AddResourceDialogComponent', () => { - let component: AddResourceDialogComponent; - let fixture: ComponentFixture; - let store: Store; - let dialogRef: jest.Mocked; - let mockDialogConfig: jest.Mocked; - - const mockRegistryId = 'registry-123'; - - beforeEach(async () => { - mockDialogConfig = { - data: { - id: mockRegistryId, - }, - } as jest.Mocked; - - await TestBed.configureTestingModule({ - imports: [ - AddResourceDialogComponent, - OSFTestingModule, - MockComponent(LoadingSpinnerComponent), - MockComponent(ResourceFormComponent), - MockComponent(IconComponent), - ], - providers: [ - DynamicDialogRefMock, - TranslateServiceMock, - MockProvider(DynamicDialogConfig, mockDialogConfig), - provideMockStore({ - signals: [ - { selector: RegistryResourcesSelectors.getCurrentResource, value: signal(null) }, - { selector: RegistryResourcesSelectors.isCurrentResourceLoading, value: signal(false) }, - ], - }), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(AddResourceDialogComponent); - component = fixture.componentInstance; - store = TestBed.inject(Store); - dialogRef = TestBed.inject(DynamicDialogRef) as jest.Mocked; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); + it('should create with default values', () => { + const { component } = setup(); - it('should initialize with default values', () => { + expect(component).toBeTruthy(); expect(component.doiDomain).toBe('https://doi.org/'); - expect(component.inputLimits).toBeDefined(); expect(component.isResourceConfirming()).toBe(false); expect(component.isPreviewMode()).toBe(false); - expect(component.resourceOptions()).toBeDefined(); }); it('should initialize form with empty values', () => { + const { component } = setup(); + expect(component.form.get('pid')?.value).toBe(''); expect(component.form.get('resourceType')?.value).toBe(''); expect(component.form.get('description')?.value).toBe(''); }); it('should validate pid with DOI validator', () => { + const { component } = setup(); const pidControl = component.form.get('pid'); + pidControl?.setValue('invalid-doi'); pidControl?.updateValueAndValidity(); @@ -92,53 +95,130 @@ describe('AddResourceDialogComponent', () => { }); it('should accept valid DOI format', () => { + const { component } = setup(); const pidControl = component.form.get('pid'); + pidControl?.setValue('10.1234/valid.doi'); expect(pidControl?.hasError('doi')).toBe(false); }); it('should not preview resource when form is invalid', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch'); - component.form.get('pid')?.setValue(''); + const { component, store } = setup(); + + (store.dispatch as jest.Mock).mockClear(); + component.previewResource(); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(component.isPreviewMode()).toBe(false); + }); + + it('should not preview resource when currentResource is null', () => { + const { component, store } = setup(); + component.form.patchValue({ pid: '10.1234/test', resourceType: 'data' }); + (store.dispatch as jest.Mock).mockClear(); component.previewResource(); - expect(dispatchSpy).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); expect(component.isPreviewMode()).toBe(false); }); - it('should throw error when previewing resource without current resource', () => { - component.form.patchValue({ - pid: '10.1234/test', - resourceType: 'dataset', + it('should preview resource and set preview mode on success', () => { + const { component, store } = setup({ + selectorOverrides: [{ selector: RegistryResourcesSelectors.getCurrentResource, value: MOCK_RESOURCE }], }); - expect(() => component.previewResource()).toThrow(); + component.form.patchValue({ pid: '10.1234/test', resourceType: 'data', description: 'desc' }); + (store.dispatch as jest.Mock).mockClear(); + component.previewResource(); + + expect(store.dispatch).toHaveBeenCalled(); + expect(component.isPreviewMode()).toBe(true); }); it('should set isPreviewMode to false when backToEdit is called', () => { - component.isPreviewMode.set(true); + const { component } = setup(); + component.isPreviewMode.set(true); component.backToEdit(); expect(component.isPreviewMode()).toBe(false); }); - it('should throw error when adding resource without current resource', () => { - expect(() => component.onAddResource()).toThrow(); + it('should not add resource when currentResource is null', () => { + const { component, store, dialogRef } = setup(); + + (store.dispatch as jest.Mock).mockClear(); + component.onAddResource(); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); }); - it('should close dialog without deleting when closeDialog is called without current resource', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch'); + it('should confirm add resource and close dialog on success', () => { + const { component, store, dialogRef } = setup({ + selectorOverrides: [{ selector: RegistryResourcesSelectors.getCurrentResource, value: MOCK_RESOURCE }], + }); + + (store.dispatch as jest.Mock).mockClear(); + component.onAddResource(); + expect(component.isResourceConfirming()).toBe(false); + expect(store.dispatch).toHaveBeenCalled(); + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + + it('should close dialog without deleting when currentResource is null', () => { + const { component, store, dialogRef } = setup(); + + (store.dispatch as jest.Mock).mockClear(); + component.closeDialog(); + + expect(dialogRef.close).toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should delete resource and close dialog when currentResource exists', () => { + const { component, store, dialogRef } = setup({ + selectorOverrides: [{ selector: RegistryResourcesSelectors.getCurrentResource, value: MOCK_RESOURCE }], + }); + + (store.dispatch as jest.Mock).mockClear(); component.closeDialog(); + expect(store.dispatch).toHaveBeenCalled(); expect(dialogRef.close).toHaveBeenCalled(); - expect(dispatchSpy).not.toHaveBeenCalled(); }); - it('should compute doiLink as undefined when current resource does not exist', () => { - expect(component.doiLink()).toBe('https://doi.org/undefined'); + it('should compute doiLink from currentResource pid', () => { + const { component } = setup({ + selectorOverrides: [{ selector: RegistryResourcesSelectors.getCurrentResource, value: MOCK_RESOURCE }], + }); + + expect(component.doiLink()).toBe('https://doi.org/10.1234/test'); + }); + + it('should return empty string for resourceTypeTranslationKey when currentResource is null', () => { + const { component } = setup(); + + expect(component.resourceTypeTranslationKey()).toBe(''); + }); + + it('should return translation key for resourceTypeTranslationKey when resource type matches', () => { + const { component } = setup({ + selectorOverrides: [{ selector: RegistryResourcesSelectors.getCurrentResource, value: MOCK_RESOURCE }], + }); + + expect(component.resourceTypeTranslationKey()).toBe('resources.typeOptions.data'); + }); + + it('should return empty string for resourceTypeTranslationKey when type is unknown', () => { + const unknownResource = { ...MOCK_RESOURCE, type: 'unknown_type' as RegistryResourceType }; + const { component } = setup({ + selectorOverrides: [{ selector: RegistryResourcesSelectors.getCurrentResource, value: unknownResource }], + }); + + expect(component.resourceTypeTranslationKey()).toBe(''); }); }); diff --git a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.ts b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.ts index 814a8cdf8..7d69a244a 100644 --- a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.ts +++ b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.ts @@ -1,11 +1,11 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { finalize, take } from 'rxjs'; +import { finalize } from 'rxjs'; import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -38,7 +38,6 @@ export class AddResourceDialogComponent { readonly dialogRef = inject(DynamicDialogRef); readonly currentResource = select(RegistryResourcesSelectors.getCurrentResource); readonly isCurrentResourceLoading = select(RegistryResourcesSelectors.isCurrentResourceLoading); - private readonly translateService = inject(TranslateService); private dialogConfig = inject(DynamicDialogConfig); private registryId: string = this.dialogConfig.data.id; @@ -61,11 +60,20 @@ export class AddResourceDialogComponent { deleteResource: SilentDelete, }); - public resourceOptions = signal(resourceTypeOptions); - public isPreviewMode = signal(false); + resourceOptions = signal(resourceTypeOptions); + isPreviewMode = signal(false); readonly RegistryResourceType = RegistryResourceType; + readonly resourceTypeTranslationKey = computed(() => { + const type = this.currentResource()?.type; + const options = this.resourceOptions(); + + if (!type || !options.length) return ''; + + return options.find((opt) => opt.value === type)?.label ?? ''; + }); + previewResource(): void { if (this.form.invalid) { return; @@ -79,7 +87,7 @@ export class AddResourceDialogComponent { const currentResource = this.currentResource(); if (!currentResource) { - throw new Error(this.translateService.instant('resources.errors.noCurrentResource')); + return; } this.actions.previewResource(currentResource.id, addResource).subscribe(() => this.isPreviewMode.set(true)); @@ -94,28 +102,23 @@ export class AddResourceDialogComponent { const currentResource = this.currentResource(); if (!currentResource) { - throw new Error(this.translateService.instant('resources.errors.noRegistryId')); + return; } this.isResourceConfirming.set(true); this.actions .confirmAddResource(addResource, currentResource.id, this.registryId) - .pipe( - take(1), - finalize(() => { - this.dialogRef.close(true); - this.isResourceConfirming.set(false); - }) - ) - .subscribe({}); + .pipe(finalize(() => this.isResourceConfirming.set(false))) + .subscribe(() => this.dialogRef.close(true)); } closeDialog(): void { - this.dialogRef.close(); const currentResource = this.currentResource(); if (currentResource) { this.actions.deleteResource(currentResource.id); } + + this.dialogRef.close(); } } diff --git a/src/app/features/registry/components/archiving-message/archiving-message.component.spec.ts b/src/app/features/registry/components/archiving-message/archiving-message.component.spec.ts index 6cf0e635c..5afea533d 100644 --- a/src/app/features/registry/components/archiving-message/archiving-message.component.spec.ts +++ b/src/app/features/registry/components/archiving-message/archiving-message.component.spec.ts @@ -1,106 +1,38 @@ import { MockComponents } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { RegistryStatus } from '@osf/shared/enums/registry-status.enum'; -import { RegistrationOverviewModel } from '../../models'; import { ShortRegistrationInfoComponent } from '../short-registration-info/short-registration-info.component'; import { ArchivingMessageComponent } from './archiving-message.component'; import { MOCK_REGISTRATION_OVERVIEW_MODEL } from '@testing/mocks/registration-overview-model.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('ArchivingMessageComponent', () => { - let component: ArchivingMessageComponent; - let fixture: ComponentFixture; - let environment: any; - - const mockRegistration: RegistrationOverviewModel = MOCK_REGISTRATION_OVERVIEW_MODEL; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - ArchivingMessageComponent, - OSFTestingModule, - ...MockComponents(IconComponent, ShortRegistrationInfoComponent), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(ArchivingMessageComponent); - component = fixture.componentInstance; - environment = TestBed.inject(ENVIRONMENT); - - fixture.componentRef.setInput('registration', mockRegistration); - fixture.detectChanges(); - }); - - it('should have support email from environment', () => { - expect(component.supportEmail).toBeDefined(); - expect(typeof component.supportEmail).toBe('string'); - expect(component.supportEmail).toBe(environment.supportEmail); - }); - - it('should receive registration input', () => { - expect(component.registration()).toEqual(mockRegistration); - }); - - it('should have registration as a required input', () => { - expect(component.registration()).toBeDefined(); - expect(component.registration()).toEqual(mockRegistration); - }); - - it('should handle different registration statuses', () => { - const statuses = [ - RegistryStatus.Accepted, - RegistryStatus.Pending, - RegistryStatus.Withdrawn, - RegistryStatus.Embargo, - ]; - - statuses.forEach((status) => { - const registrationWithStatus = { ...mockRegistration, status }; - fixture.componentRef.setInput('registration', registrationWithStatus); - fixture.detectChanges(); - - expect(component.registration().status).toBe(status); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArchivingMessageComponent, ...MockComponents(ShortRegistrationInfoComponent)], + providers: [provideOSFCore()], }); }); - it('should be reactive to registration input changes', () => { - const updatedRegistration = { ...mockRegistration, title: 'Updated Title' }; - - fixture.componentRef.setInput('registration', updatedRegistration); + it('should create and receive registration input', () => { + const fixture = TestBed.createComponent(ArchivingMessageComponent); + fixture.componentRef.setInput('registration', MOCK_REGISTRATION_OVERVIEW_MODEL); fixture.detectChanges(); - expect(component.registration().title).toBe('Updated Title'); + expect(fixture.componentInstance).toBeTruthy(); + expect(fixture.componentInstance.registration()).toEqual(MOCK_REGISTRATION_OVERVIEW_MODEL); }); - it('should update when registration properties change', () => { - const updatedRegistration = { - ...mockRegistration, - title: 'New Title', - description: 'New Description', - }; - - fixture.componentRef.setInput('registration', updatedRegistration); - fixture.detectChanges(); - - expect(component.registration().title).toBe('New Title'); - expect(component.registration().description).toBe('New Description'); - }); - - it('should maintain supportEmail reference when registration changes', () => { - const initialSupportEmail = component.supportEmail; - const updatedRegistration = { ...mockRegistration, title: 'Different Title' }; - - fixture.componentRef.setInput('registration', updatedRegistration); + it('should have supportEmail from environment', () => { + const fixture = TestBed.createComponent(ArchivingMessageComponent); + fixture.componentRef.setInput('registration', MOCK_REGISTRATION_OVERVIEW_MODEL); fixture.detectChanges(); - expect(component.supportEmail).toBe(initialSupportEmail); - expect(component.supportEmail).toBe(environment.supportEmail); + expect(fixture.componentInstance.supportEmail).toBe(TestBed.inject(ENVIRONMENT).supportEmail); }); }); diff --git a/src/app/features/registry/components/archiving-message/archiving-message.component.ts b/src/app/features/registry/components/archiving-message/archiving-message.component.ts index 3525ed5aa..bf66ce905 100644 --- a/src/app/features/registry/components/archiving-message/archiving-message.component.ts +++ b/src/app/features/registry/components/archiving-message/archiving-message.component.ts @@ -13,7 +13,7 @@ import { ShortRegistrationInfoComponent } from '../short-registration-info/short @Component({ selector: 'osf-archiving-message', - imports: [TranslatePipe, Card, IconComponent, Divider, ShortRegistrationInfoComponent], + imports: [Card, Divider, IconComponent, ShortRegistrationInfoComponent, TranslatePipe], templateUrl: './archiving-message.component.html', styleUrl: './archiving-message.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.spec.ts b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.spec.ts index c4f9447ff..e334b961c 100644 --- a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.spec.ts +++ b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.spec.ts @@ -2,11 +2,11 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; -import { DynamicDialogConfig } from 'primeng/dynamicdialog'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { RegistryResourceType } from '@osf/shared/enums/registry-resource.enum'; @@ -17,168 +17,94 @@ import { ResourceFormComponent } from '../resource-form/resource-form.component' import { EditResourceDialogComponent } from './edit-resource-dialog.component'; -import { DynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +const MOCK_RESOURCE: RegistryResource = { + id: 'resource-123', + pid: '10.1234/test.doi', + type: RegistryResourceType.Data, + description: 'Test resource description', + finalized: false, +}; + describe('EditResourceDialogComponent', () => { - let component: EditResourceDialogComponent; - let fixture: ComponentFixture; - let store: Store; - let mockDialogConfig: jest.Mocked; - - const mockRegistryId = 'registry-123'; - const mockResource: RegistryResource = { - id: 'resource-123', - pid: '10.1234/test.doi', - type: RegistryResourceType.Data, - description: 'Test resource description', - finalized: false, - }; - - beforeEach(async () => { - mockDialogConfig = { - data: { - id: mockRegistryId, - resource: mockResource, - }, - } as jest.Mocked; - - await TestBed.configureTestingModule({ - imports: [ - EditResourceDialogComponent, - OSFTestingModule, - ...MockComponents(LoadingSpinnerComponent, ResourceFormComponent), - ], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [EditResourceDialogComponent, ...MockComponents(LoadingSpinnerComponent, ResourceFormComponent)], providers: [ - DynamicDialogRefMock, - MockProvider(DynamicDialogConfig, mockDialogConfig), + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { data: { id: 'registry-123', resource: MOCK_RESOURCE } }), provideMockStore({ signals: [{ selector: RegistryResourcesSelectors.isCurrentResourceLoading, value: false }], }), ], - }).compileComponents(); - - fixture = TestBed.createComponent(EditResourceDialogComponent); - component = fixture.componentInstance; - store = TestBed.inject(Store); - fixture.detectChanges(); + }); }); it('should initialize form with resource data', () => { - expect(component.form.get('pid')?.value).toBe(mockResource.pid); - expect(component.form.get('resourceType')?.value).toBe(mockResource.type); - expect(component.form.get('description')?.value).toBe(mockResource.description); - }); - - it('should have required validators on pid and resourceType', () => { - const pidControl = component.form.get('pid'); - const resourceTypeControl = component.form.get('resourceType'); - - expect(pidControl?.hasError('required')).toBe(false); - expect(resourceTypeControl?.hasError('required')).toBe(false); - }); - - it('should validate pid with DOI validator when invalid format', () => { - const pidControl = component.form.get('pid'); - pidControl?.setValue('invalid-doi'); - pidControl?.updateValueAndValidity(); - - const hasDoiError = pidControl?.hasError('doi') || pidControl?.hasError('invalidDoi'); - expect(hasDoiError).toBe(true); - }); - - it('should accept valid DOI format', () => { - const pidControl = component.form.get('pid'); - pidControl?.setValue('10.1234/valid.doi'); + const fixture = TestBed.createComponent(EditResourceDialogComponent); + fixture.detectChanges(); - expect(pidControl?.hasError('doi')).toBe(false); + expect(fixture.componentInstance.form.value).toEqual({ + pid: MOCK_RESOURCE.pid, + resourceType: MOCK_RESOURCE.type, + description: MOCK_RESOURCE.description, + }); }); - it('should mark form as invalid when pid is empty', () => { - component.form.get('pid')?.setValue(''); - component.form.get('pid')?.markAsTouched(); - - expect(component.form.invalid).toBe(true); - }); + it('should not dispatch when form is invalid', () => { + const fixture = TestBed.createComponent(EditResourceDialogComponent); + fixture.detectChanges(); + const store = TestBed.inject(Store); + const dispatchSpy = jest.spyOn(store, 'dispatch'); - it('should mark form as invalid when resourceType is empty', () => { - component.form.get('resourceType')?.setValue(''); - component.form.get('resourceType')?.markAsTouched(); + fixture.componentInstance.form.get('pid')?.setValue(''); + fixture.componentInstance.save(); - expect(component.form.invalid).toBe(true); + expect(dispatchSpy).not.toHaveBeenCalled(); }); - it('should mark form as valid when all required fields are filled with valid values', () => { - component.form.patchValue({ - pid: '10.1234/test', - resourceType: 'dataset', - description: 'Test description', - }); - - expect(component.form.valid).toBe(true); - }); + it('should dispatch UpdateResource and close dialog on success', () => { + const fixture = TestBed.createComponent(EditResourceDialogComponent); + fixture.detectChanges(); + const store = TestBed.inject(Store); + const dialogRef = TestBed.inject(DynamicDialogRef); + jest.spyOn(store, 'dispatch').mockReturnValue(of(undefined)); - it('should dispatch UpdateResource action with correct parameters when form is valid', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch').mockReturnValue(of()); - component.form.patchValue({ + fixture.componentInstance.form.patchValue({ pid: '10.1234/updated', resourceType: 'paper', description: 'Updated description', }); + fixture.componentInstance.save(); - component.save(); - - expect(dispatchSpy).toHaveBeenCalledWith( + expect(store.dispatch).toHaveBeenCalledWith( expect.objectContaining({ - registryId: mockRegistryId, - resourceId: mockResource.id, - resource: expect.objectContaining({ - pid: '10.1234/updated', - resource_type: 'paper', - description: 'Updated description', - }), + registryId: 'registry-123', + resourceId: MOCK_RESOURCE.id, + resource: { pid: '10.1234/updated', resource_type: 'paper', description: 'Updated description' }, }) ); + expect(dialogRef.close).toHaveBeenCalledWith(true); }); - it('should handle empty description', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch').mockReturnValue(of()); - component.form.patchValue({ - pid: '10.1234/test', - resourceType: 'dataset', - description: '', - }); - - component.save(); - - expect(dispatchSpy).toHaveBeenCalledWith( - expect.objectContaining({ - resource: expect.objectContaining({ - description: '', - }), - }) - ); - }); + it('should not close dialog on dispatch error', () => { + const fixture = TestBed.createComponent(EditResourceDialogComponent); + fixture.detectChanges(); + const store = TestBed.inject(Store); + const dialogRef = TestBed.inject(DynamicDialogRef); + jest.spyOn(store, 'dispatch').mockReturnValue(throwError(() => new Error('fail'))); - it('should handle null form values by converting to empty strings', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch').mockReturnValue(of()); - component.form.patchValue({ - pid: '10.1234/test', - resourceType: 'dataset', - description: null, + fixture.componentInstance.form.patchValue({ + pid: '10.1234/updated', + resourceType: 'paper', + description: '', }); + fixture.componentInstance.save(); - component.save(); - - expect(dispatchSpy).toHaveBeenCalledWith( - expect.objectContaining({ - resource: expect.objectContaining({ - pid: '10.1234/test', - resource_type: 'dataset', - description: '', - }), - }) - ); + expect(dialogRef.close).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts index a6e84c692..247da58b1 100644 --- a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts +++ b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts @@ -2,8 +2,6 @@ import { createDispatchMap, select } from '@ngxs/store'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { finalize, take } from 'rxjs'; - import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -16,7 +14,7 @@ import { ResourceFormComponent } from '../resource-form/resource-form.component' @Component({ selector: 'osf-edit-resource-dialog', - imports: [LoadingSpinnerComponent, ReactiveFormsModule, ResourceFormComponent], + imports: [ReactiveFormsModule, LoadingSpinnerComponent, ResourceFormComponent], templateUrl: './edit-resource-dialog.component.html', styleUrl: './edit-resource-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -25,7 +23,7 @@ export class EditResourceDialogComponent { readonly dialogRef = inject(DynamicDialogRef); readonly isCurrentResourceLoading = select(RegistryResourcesSelectors.isCurrentResourceLoading); - private dialogConfig = inject(DynamicDialogConfig); + private readonly dialogConfig = inject(DynamicDialogConfig); private registryId: string = this.dialogConfig.data.id; private resource: RegistryResource = this.dialogConfig.data.resource as RegistryResource; @@ -58,12 +56,6 @@ export class EditResourceDialogComponent { this.actions .updateResource(this.registryId, this.resource.id, addResource) - .pipe( - take(1), - finalize(() => { - this.dialogRef.close(true); - }) - ) - .subscribe(); + .subscribe(() => this.dialogRef.close(true)); } } diff --git a/src/app/features/registry/components/registration-links-card/registration-links-card.component.html b/src/app/features/registry/components/registration-links-card/registration-links-card.component.html index 612052d22..6e2f582b3 100644 --- a/src/app/features/registry/components/registration-links-card/registration-links-card.component.html +++ b/src/app/features/registry/components/registration-links-card/registration-links-card.component.html @@ -9,7 +9,7 @@ styleClass="text-lg" link [label]="registrationData().title || 'project.registrations.card.noTitle' | translate" - (click)="reviewEmitRegistrationData.emit(registrationData()!.id)" + (onClick)="reviewEmitRegistrationData.emit(registrationData()!.id)" >
@@ -82,17 +82,13 @@ - @if ( - registrationDataTyped() && - registrationDataTyped()?.currentUserPermissions && - registrationDataTyped()!.currentUserPermissions.length > 1 - ) { + @if (hasWriteAccess()) { } diff --git a/src/app/features/registry/components/registration-links-card/registration-links-card.component.spec.ts b/src/app/features/registry/components/registration-links-card/registration-links-card.component.spec.ts index f10170db1..1155f3d51 100644 --- a/src/app/features/registry/components/registration-links-card/registration-links-card.component.spec.ts +++ b/src/app/features/registry/components/registration-links-card/registration-links-card.component.spec.ts @@ -1,6 +1,6 @@ import { MockComponents } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { DataResourcesComponent } from '@osf/shared/components/data-resources/data-resources.component'; @@ -11,110 +11,77 @@ import { RegistrationLinksCardComponent } from './registration-links-card.compon import { createMockLinkedNode } from '@testing/mocks/linked-node.mock'; import { createMockLinkedRegistration } from '@testing/mocks/linked-registration.mock'; import { createMockRegistryComponent } from '@testing/mocks/registry-component.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { MockComponentWithSignal } from '@testing/providers/component-provider.mock'; describe('RegistrationLinksCardComponent', () => { - let component: RegistrationLinksCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ + beforeEach(() => { + TestBed.configureTestingModule({ imports: [ RegistrationLinksCardComponent, - OSFTestingModule, ...MockComponents(DataResourcesComponent, IconComponent, ContributorsListComponent), + MockComponentWithSignal('osf-truncated-text'), ], - }).compileComponents(); - - fixture = TestBed.createComponent(RegistrationLinksCardComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.componentRef.setInput('registrationData', createMockLinkedRegistration()); - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should set registrationData input correctly with LinkedRegistration', () => { - const mockLinkedRegistration = createMockLinkedRegistration(); - fixture.componentRef.setInput('registrationData', mockLinkedRegistration); - fixture.detectChanges(); - - expect(component.registrationData()).toEqual(mockLinkedRegistration); - }); - - it('should set registrationData input correctly with LinkedNode', () => { - const mockLinkedNode = createMockLinkedNode(); - fixture.componentRef.setInput('registrationData', mockLinkedNode); - fixture.detectChanges(); - - expect(component.registrationData()).toEqual(mockLinkedNode); + providers: [provideOSFCore()], + }); }); - it('should set registrationData input correctly with RegistryComponentModel', () => { - const mockRegistryComponent = createMockRegistryComponent(); - fixture.componentRef.setInput('registrationData', mockRegistryComponent); - fixture.detectChanges(); - - expect(component.registrationData()).toEqual(mockRegistryComponent); - }); - - it('should return true when data has reviewsState property', () => { + it('should identify LinkedRegistration data', () => { + const fixture = TestBed.createComponent(RegistrationLinksCardComponent); fixture.componentRef.setInput('registrationData', createMockLinkedRegistration()); fixture.detectChanges(); - expect(component.isRegistrationData()).toBe(true); + expect(fixture.componentInstance.isRegistrationData()).toBe(true); + expect(fixture.componentInstance.registrationDataTyped()).toEqual(createMockLinkedRegistration()); }); - it('should return false when data does not have reviewsState property', () => { + it('should identify LinkedNode data', () => { + const fixture = TestBed.createComponent(RegistrationLinksCardComponent); fixture.componentRef.setInput('registrationData', createMockLinkedNode()); fixture.detectChanges(); - expect(component.isRegistrationData()).toBe(false); + expect(fixture.componentInstance.isRegistrationData()).toBe(false); + expect(fixture.componentInstance.isComponentData()).toBe(false); + expect(fixture.componentInstance.registrationDataTyped()).toBeNull(); + expect(fixture.componentInstance.componentsDataTyped()).toBeNull(); }); - it('should return true when data has registrationSupplement property', () => { + it('should identify RegistryComponentModel data', () => { + const fixture = TestBed.createComponent(RegistrationLinksCardComponent); fixture.componentRef.setInput('registrationData', createMockRegistryComponent()); fixture.detectChanges(); - expect(component.isComponentData()).toBe(true); - }); - - it('should return true for LinkedRegistration with registrationSupplement', () => { - fixture.componentRef.setInput('registrationData', createMockLinkedRegistration()); - fixture.detectChanges(); - - expect(component.isComponentData()).toBe(true); + expect(fixture.componentInstance.isComponentData()).toBe(true); + expect(fixture.componentInstance.componentsDataTyped()).toEqual(createMockRegistryComponent()); }); - it('should return false when data does not have registrationSupplement property', () => { - fixture.componentRef.setInput('registrationData', createMockLinkedNode()); + it('should return true for hasWriteAccess when user has write permission', () => { + const fixture = TestBed.createComponent(RegistrationLinksCardComponent); + fixture.componentRef.setInput( + 'registrationData', + createMockLinkedRegistration({ currentUserPermissions: ['read', 'write'] }) + ); fixture.detectChanges(); - expect(component.isComponentData()).toBe(false); + expect(fixture.componentInstance.hasWriteAccess()).toBe(true); }); - it('should return LinkedRegistration when data has reviewsState', () => { - const mockLinkedRegistration = createMockLinkedRegistration(); - fixture.componentRef.setInput('registrationData', mockLinkedRegistration); + it('should return false for hasWriteAccess when user has read-only permission', () => { + const fixture = TestBed.createComponent(RegistrationLinksCardComponent); + fixture.componentRef.setInput( + 'registrationData', + createMockLinkedRegistration({ currentUserPermissions: ['read'] }) + ); fixture.detectChanges(); - expect(component.registrationDataTyped()).toEqual(mockLinkedRegistration); + expect(fixture.componentInstance.hasWriteAccess()).toBe(false); }); - it('should return null when data does not have reviewsState', () => { + it('should return false for hasWriteAccess for non-registration data', () => { + const fixture = TestBed.createComponent(RegistrationLinksCardComponent); fixture.componentRef.setInput('registrationData', createMockLinkedNode()); fixture.detectChanges(); - expect(component.registrationDataTyped()).toBeNull(); - }); - - it('should return RegistryComponentModel when data has registrationSupplement', () => { - const mockRegistryComponent = createMockRegistryComponent(); - fixture.componentRef.setInput('registrationData', mockRegistryComponent); - fixture.detectChanges(); - - expect(component.componentsDataTyped()).toEqual(mockRegistryComponent); + expect(fixture.componentInstance.hasWriteAccess()).toBe(false); }); }); diff --git a/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts b/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts index a050662ce..f585d316a 100644 --- a/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts +++ b/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts @@ -10,7 +10,7 @@ import { ContributorsListComponent } from '@osf/shared/components/contributors-l import { DataResourcesComponent } from '@osf/shared/components/data-resources/data-resources.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; -import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { LinkedNode, LinkedRegistration, RegistryComponentModel } from '../../models'; @@ -36,8 +36,6 @@ export class RegistrationLinksCardComponent { readonly updateEmitRegistrationData = output(); readonly reviewEmitRegistrationData = output(); - readonly RevisionReviewStates = RevisionReviewStates; - readonly isRegistrationData = computed(() => { const data = this.registrationData(); return 'reviewsState' in data; @@ -57,4 +55,8 @@ export class RegistrationLinksCardComponent { const data = this.registrationData(); return this.isComponentData() ? (data as RegistryComponentModel) : null; }); + + readonly hasWriteAccess = computed( + () => this.registrationDataTyped()?.currentUserPermissions?.includes(UserPermissions.Write) ?? false + ); } diff --git a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.spec.ts b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.spec.ts index 56a81a601..765ac611f 100644 --- a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.spec.ts +++ b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.spec.ts @@ -4,7 +4,7 @@ import { MockComponent, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { UserSelectors } from '@core/store/user'; import { SocialsShareButtonComponent } from '@osf/shared/components/socials-share-button/socials-share-button.component'; @@ -14,121 +14,123 @@ import { BookmarksSelectors } from '@osf/shared/stores/bookmarks'; import { RegistrationOverviewToolbarComponent } from './registration-overview-toolbar.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; - -describe('RegistrationOverviewToolbarComponent', () => { - let component: RegistrationOverviewToolbarComponent; - let fixture: ComponentFixture; - let store: jest.Mocked; - let toastService: ReturnType; - - const mockResourceId = 'registration-123'; - const mockResourceTitle = 'Test Registration'; - const mockBookmarksCollectionId = 'bookmarks-123'; - - beforeEach(async () => { - toastService = ToastServiceMockBuilder.create().build(); - - await TestBed.configureTestingModule({ - imports: [RegistrationOverviewToolbarComponent, OSFTestingModule, MockComponent(SocialsShareButtonComponent)], - providers: [ - provideMockStore({ - signals: [ - { selector: BookmarksSelectors.getBookmarksCollectionId, value: mockBookmarksCollectionId }, - { selector: BookmarksSelectors.getBookmarks, value: [] }, - { selector: BookmarksSelectors.areBookmarksLoading, value: false }, - { selector: BookmarksSelectors.getBookmarksCollectionIdSubmitting, value: false }, - { selector: UserSelectors.isAuthenticated, value: true }, - ], - }), - MockProvider(ToastService, toastService), - ], - }).compileComponents(); - - store = TestBed.inject(Store) as jest.Mocked; - store.dispatch = jest.fn().mockReturnValue(of(true)); - - fixture = TestBed.createComponent(RegistrationOverviewToolbarComponent); - component = fixture.componentInstance; - - fixture.componentRef.setInput('resourceId', mockResourceId); - fixture.componentRef.setInput('resourceTitle', mockResourceTitle); - fixture.componentRef.setInput('isPublic', true); - }); - - it('should set resourceId input correctly', () => { - fixture.detectChanges(); - expect(component.resourceId()).toBe(mockResourceId); +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; + +const MOCK_RESOURCE_ID = 'registration-123'; +const MOCK_BOOKMARKS_COLLECTION_ID = 'bookmarks-123'; + +interface SetupOverrides { + bookmarks?: { id: string }[]; + bookmarksCollectionId?: string | null; + isAuthenticated?: boolean; + selectorOverrides?: SignalOverride[]; +} + +function setup(overrides: SetupOverrides = {}) { + const mockToastService = ToastServiceMock.simple(); + + const defaultSignals = [ + { + selector: BookmarksSelectors.getBookmarksCollectionId, + value: 'bookmarksCollectionId' in overrides ? overrides.bookmarksCollectionId : MOCK_BOOKMARKS_COLLECTION_ID, + }, + { selector: BookmarksSelectors.getBookmarks, value: overrides.bookmarks ?? [] }, + { selector: BookmarksSelectors.areBookmarksLoading, value: false }, + { selector: BookmarksSelectors.getBookmarksCollectionIdSubmitting, value: false }, + { selector: UserSelectors.isAuthenticated, value: overrides.isAuthenticated ?? true }, + ]; + + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [RegistrationOverviewToolbarComponent, MockComponent(SocialsShareButtonComponent)], + providers: [provideOSFCore(), MockProvider(ToastService, mockToastService), provideMockStore({ signals })], }); - it('should set resourceTitle input correctly', () => { - fixture.detectChanges(); - expect(component.resourceTitle()).toBe(mockResourceTitle); - }); + const store = TestBed.inject(Store); + const fixture = TestBed.createComponent(RegistrationOverviewToolbarComponent); + fixture.componentRef.setInput('resourceId', MOCK_RESOURCE_ID); + fixture.componentRef.setInput('resourceTitle', 'Test Registration'); + fixture.componentRef.setInput('isPublic', true); + fixture.detectChanges(); - it('should set isPublic input correctly', () => { - fixture.detectChanges(); - expect(component.isPublic()).toBe(true); - }); + return { fixture, component: fixture.componentInstance, store, mockToastService }; +} - it('should dispatch GetResourceBookmark when bookmarksCollectionId and resourceId exist', () => { - fixture.detectChanges(); +describe('RegistrationOverviewToolbarComponent', () => { + it('should dispatch GetResourceBookmark on init', () => { + const { store } = setup(); expect(store.dispatch).toHaveBeenCalledWith( expect.objectContaining({ - bookmarkCollectionId: mockBookmarksCollectionId, - resourceId: mockResourceId, + bookmarkCollectionId: MOCK_BOOKMARKS_COLLECTION_ID, + resourceId: MOCK_RESOURCE_ID, resourceType: ResourceType.Registration, }) ); }); - it('should set isBookmarked to false when bookmarks array is empty', () => { - fixture.detectChanges(); - expect(component.isBookmarked()).toBe(false); + it('should not dispatch GetResourceBookmark when bookmarksCollectionId is null', () => { + const { store } = setup({ bookmarksCollectionId: null }); + + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should not do anything when resourceId is missing', () => { - fixture.componentRef.setInput('resourceId', ''); - fixture.detectChanges(); + it('should compute isBookmarked from bookmarks', () => { + const { component } = setup({ bookmarks: [{ id: MOCK_RESOURCE_ID }] }); - component.toggleBookmark(); + expect(component.isBookmarked()).toBe(true); + }); - expect(store.dispatch).not.toHaveBeenCalled(); - expect(toastService.showSuccess).not.toHaveBeenCalled(); + it('should compute isBookmarked as false when not in bookmarks', () => { + const { component } = setup({ bookmarks: [{ id: 'other-id' }] }); + + expect(component.isBookmarked()).toBe(false); }); - it('should add bookmark when isBookmarked is false', () => { - fixture.detectChanges(); - component.isBookmarked.set(false); - jest.clearAllMocks(); + it('should dispatch add bookmark when not bookmarked', () => { + const { component, store, mockToastService } = setup(); + jest.spyOn(store, 'dispatch').mockReturnValue(of(undefined)); component.toggleBookmark(); expect(store.dispatch).toHaveBeenCalledWith( expect.objectContaining({ - bookmarkCollectionId: mockBookmarksCollectionId, - resourceId: mockResourceId, + bookmarkCollectionId: MOCK_BOOKMARKS_COLLECTION_ID, + resourceId: MOCK_RESOURCE_ID, resourceType: ResourceType.Registration, }) ); + expect(mockToastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.bookmark.add'); }); - it('should remove bookmark when isBookmarked is true', () => { - fixture.detectChanges(); - component.isBookmarked.set(true); - jest.clearAllMocks(); + it('should dispatch remove bookmark when already bookmarked', () => { + const { component, store, mockToastService } = setup({ bookmarks: [{ id: MOCK_RESOURCE_ID }] }); + jest.spyOn(store, 'dispatch').mockReturnValue(of(undefined)); component.toggleBookmark(); expect(store.dispatch).toHaveBeenCalledWith( expect.objectContaining({ - bookmarkCollectionId: mockBookmarksCollectionId, - resourceId: mockResourceId, + bookmarkCollectionId: MOCK_BOOKMARKS_COLLECTION_ID, + resourceId: MOCK_RESOURCE_ID, resourceType: ResourceType.Registration, }) ); + expect(mockToastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.bookmark.remove'); + }); + + it('should not dispatch toggleBookmark when resourceId is empty', () => { + const { fixture, store, mockToastService } = setup(); + fixture.componentRef.setInput('resourceId', ''); + fixture.detectChanges(); + jest.spyOn(store, 'dispatch').mockClear().mockReturnValue(of(undefined)); + + fixture.componentInstance.toggleBookmark(); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(mockToastService.showSuccess).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.ts b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.ts index 0038252f3..00640ee98 100644 --- a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.ts +++ b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.ts @@ -5,7 +5,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Tooltip } from 'primeng/tooltip'; -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, input } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { UserSelectors } from '@core/store/user'; @@ -27,23 +27,26 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistrationOverviewToolbarComponent { - private toastService = inject(ToastService); - private destroyRef = inject(DestroyRef); + private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); - resourceId = input.required(); - resourceTitle = input.required(); - isPublic = input(false); + readonly resourceId = input.required(); + readonly resourceTitle = input.required(); + readonly isPublic = input(false); - isBookmarked = signal(false); - resourceType = ResourceType.Registration; + readonly resourceType = ResourceType.Registration; - bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); - bookmarks = select(BookmarksSelectors.getBookmarks); - isBookmarksLoading = select(BookmarksSelectors.areBookmarksLoading); - isBookmarksSubmitting = select(BookmarksSelectors.getBookmarksCollectionIdSubmitting); - isAuthenticated = select(UserSelectors.isAuthenticated); + readonly bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); + readonly bookmarks = select(BookmarksSelectors.getBookmarks); + readonly isBookmarksLoading = select(BookmarksSelectors.areBookmarksLoading); + readonly isBookmarksSubmitting = select(BookmarksSelectors.getBookmarksCollectionIdSubmitting); + readonly isAuthenticated = select(UserSelectors.isAuthenticated); - actions = createDispatchMap({ + readonly isBookmarked = computed( + () => this.bookmarks()?.some((bookmark) => bookmark.id === this.resourceId()) ?? false + ); + + private readonly actions = createDispatchMap({ getResourceBookmark: GetResourceBookmark, addResourceToBookmarks: AddResourceToBookmarks, removeResourceFromBookmarks: RemoveResourceFromBookmarks, @@ -57,17 +60,6 @@ export class RegistrationOverviewToolbarComponent { this.actions.getResourceBookmark(bookmarksCollectionId, this.resourceId(), this.resourceType); }); - - effect(() => { - const bookmarks = this.bookmarks(); - - if (!this.resourceId() || !bookmarks?.length) { - this.isBookmarked.set(false); - return; - } - - this.isBookmarked.set(bookmarks.some((bookmark) => bookmark.id === this.resourceId())); - }); } toggleBookmark(): void { @@ -75,24 +67,14 @@ export class RegistrationOverviewToolbarComponent { if (!this.resourceId() || !bookmarksId) return; - const newBookmarkState = !this.isBookmarked(); - - if (newBookmarkState) { - this.actions - .addResourceToBookmarks(bookmarksId, this.resourceId(), this.resourceType) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.isBookmarked.set(newBookmarkState); - this.toastService.showSuccess('project.overview.dialog.toast.bookmark.add'); - }); - } else { - this.actions - .removeResourceFromBookmarks(bookmarksId, this.resourceId(), this.resourceType) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.isBookmarked.set(newBookmarkState); - this.toastService.showSuccess('project.overview.dialog.toast.bookmark.remove'); - }); - } + const action = this.isBookmarked() + ? this.actions.removeResourceFromBookmarks(bookmarksId, this.resourceId(), this.resourceType) + : this.actions.addResourceToBookmarks(bookmarksId, this.resourceId(), this.resourceType); + + const toastKey = this.isBookmarked() + ? 'project.overview.dialog.toast.bookmark.remove' + : 'project.overview.dialog.toast.bookmark.add'; + + action.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.toastService.showSuccess(toastKey)); } } diff --git a/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.html b/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.html index a615d5e11..508d4540c 100644 --- a/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.html +++ b/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.html @@ -15,7 +15,7 @@ severity="danger" [disabled]="form.invalid" (onClick)="withdrawRegistration()" - [loading]="submitting" + [loading]="submitting()" >
diff --git a/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.spec.ts b/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.spec.ts index 5e05c64ca..c9f30842c 100644 --- a/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.spec.ts +++ b/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.spec.ts @@ -1,74 +1,83 @@ +import { Store } from '@ngxs/store'; + import { MockComponent, MockProvider } from 'ng-mocks'; -import { DynamicDialogConfig } from 'primeng/dynamicdialog'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { of, throwError } from 'rxjs'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; import { RegistrationWithdrawDialogComponent } from './registration-withdraw-dialog.component'; -import { DynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -describe('RegistrationWithdrawDialogComponent', () => { - let component: RegistrationWithdrawDialogComponent; - let fixture: ComponentFixture; - let mockDialogConfig: jest.Mocked; - - beforeEach(async () => { - mockDialogConfig = { - data: { registryId: 'test-registry-id' }, - } as jest.Mocked; - - await TestBed.configureTestingModule({ - imports: [RegistrationWithdrawDialogComponent, OSFTestingModule, MockComponent(TextInputComponent)], - providers: [ - DynamicDialogRefMock, - MockProvider(DynamicDialogConfig, mockDialogConfig), - provideMockStore({ - signals: [], - }), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(RegistrationWithdrawDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); +function setup(registryId = 'reg-123') { + TestBed.configureTestingModule({ + imports: [RegistrationWithdrawDialogComponent, MockComponent(TextInputComponent)], + providers: [ + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { data: { registryId } }), + provideMockStore(), + ], }); - it('should create', () => { + const store = TestBed.inject(Store); + const dialogRef = TestBed.inject(DynamicDialogRef); + const fixture = TestBed.createComponent(RegistrationWithdrawDialogComponent); + fixture.detectChanges(); + + return { fixture, component: fixture.componentInstance, store, dialogRef }; +} + +describe('RegistrationWithdrawDialogComponent', () => { + it('should create with default form state', () => { + const { component } = setup(); + expect(component).toBeTruthy(); + expect(component.submitting()).toBe(false); + expect(component.form.controls.text.value).toBe(''); }); - it('should initialize with default values', () => { - expect(component.submitting).toBe(false); - expect(component.form.get('text')?.value).toBe(''); + it('should dispatch withdraw and close dialog on success', () => { + const { component, store, dialogRef } = setup(); + jest.spyOn(store, 'dispatch').mockReturnValue(of(undefined)); + + component.form.controls.text.setValue('Withdrawal reason'); + component.withdrawRegistration(); + + expect(store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + registryId: 'reg-123', + justification: 'Withdrawal reason', + }) + ); + expect(component.submitting()).toBe(false); + expect(dialogRef.close).toHaveBeenCalled(); }); - it('should have form validators', () => { - const textControl = component.form.get('text'); + it('should not close dialog on dispatch error', () => { + const { component, store, dialogRef } = setup(); + jest.spyOn(store, 'dispatch').mockReturnValue(throwError(() => new Error('fail'))); - expect(textControl?.hasError('required')).toBe(true); + component.form.controls.text.setValue('Reason'); + component.withdrawRegistration(); - textControl?.setValue('Valid withdrawal reason'); - expect(textControl?.hasError('required')).toBe(false); + expect(component.submitting()).toBe(false); + expect(dialogRef.close).not.toHaveBeenCalled(); }); - it('should handle form validation state', () => { - expect(component.form.valid).toBe(false); - - component.form.patchValue({ - text: 'Valid withdrawal reason', - }); - - expect(component.form.valid).toBe(true); + it('should not dispatch when registryId is missing', () => { + const { component, store, dialogRef } = setup(''); - component.form.patchValue({ - text: '', - }); + component.withdrawRegistration(); - expect(component.form.valid).toBe(false); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.ts b/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.ts index 8ace79288..b189debd7 100644 --- a/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.ts +++ b/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.ts @@ -5,9 +5,9 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { finalize, take } from 'rxjs'; +import { finalize } from 'rxjs'; -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { WithdrawRegistration } from '@osf/features/registry/store/registry'; @@ -17,7 +17,7 @@ import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.hel @Component({ selector: 'osf-registration-withdraw-dialog', - imports: [TranslatePipe, TextInputComponent, Button], + imports: [Button, TextInputComponent, TranslatePipe], templateUrl: './registration-withdraw-dialog.component.html', styleUrl: './registration-withdraw-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -32,22 +32,16 @@ export class RegistrationWithdrawDialogComponent { }); readonly inputLimits = InputLimits; - submitting = false; + readonly submitting = signal(false); withdrawRegistration(): void { const registryId = this.config.data.registryId; - if (registryId) { - this.submitting = true; - this.actions - .withdrawRegistration(registryId, this.form.controls.text.value ?? '') - .pipe( - take(1), - finalize(() => { - this.submitting = false; - this.dialogRef.close(); - }) - ) - .subscribe(); - } + if (!registryId) return; + + this.submitting.set(true); + this.actions + .withdrawRegistration(registryId, this.form.controls.text.value ?? '') + .pipe(finalize(() => this.submitting.set(false))) + .subscribe(() => this.dialogRef.close()); } } diff --git a/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.spec.ts b/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.spec.ts index 595364d11..9e835c38c 100644 --- a/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.spec.ts +++ b/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.spec.ts @@ -1,118 +1,52 @@ import { MockComponent } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.component'; import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; -import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; import { RegistryBlocksSectionComponent } from './registry-blocks-section.component'; import { createMockPageSchema } from '@testing/mocks/page-schema.mock'; import { createMockSchemaResponse } from '@testing/mocks/schema-response.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('RegistryBlocksSectionComponent', () => { - let component: RegistryBlocksSectionComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RegistryBlocksSectionComponent, OSFTestingModule, MockComponent(RegistrationBlocksDataComponent)], - }).compileComponents(); - - fixture = TestBed.createComponent(RegistryBlocksSectionComponent); - component = fixture.componentInstance; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RegistryBlocksSectionComponent, MockComponent(RegistrationBlocksDataComponent)], + providers: [provideOSFCore()], + }); }); - it('should create', () => { - fixture.componentRef.setInput('schemaBlocks', []); + it('should create with required inputs', () => { + const fixture = TestBed.createComponent(RegistryBlocksSectionComponent); + fixture.componentRef.setInput('schemaBlocks', [createMockPageSchema()]); fixture.componentRef.setInput('schemaResponse', null); fixture.detectChanges(); - expect(component).toBeTruthy(); + expect(fixture.componentInstance).toBeTruthy(); + expect(fixture.componentInstance.isLoading()).toBe(false); }); - it('should set schemaBlocks input correctly', () => { - const mockBlocks: PageSchema[] = [createMockPageSchema()]; - fixture.componentRef.setInput('schemaBlocks', mockBlocks); - fixture.componentRef.setInput('schemaResponse', null); - fixture.detectChanges(); - - expect(component.schemaBlocks()).toEqual(mockBlocks); - }); - - it('should set schemaResponse input correctly', () => { + it('should compute updatedFields from schemaResponse', () => { + const fixture = TestBed.createComponent(RegistryBlocksSectionComponent); const mockResponse = createMockSchemaResponse('response-1', RevisionReviewStates.Approved); - fixture.componentRef.setInput('schemaBlocks', []); - fixture.componentRef.setInput('schemaResponse', mockResponse); - fixture.detectChanges(); + mockResponse.updatedResponseKeys = ['key1', 'key2']; - expect(component.schemaResponse()).toEqual(mockResponse); - }); - - it('should default isLoading to false', () => { - fixture.componentRef.setInput('schemaBlocks', []); - fixture.componentRef.setInput('schemaResponse', null); - fixture.detectChanges(); - - expect(component.isLoading()).toBe(false); - }); - - it('should compute updatedFields from schemaResponse with updatedResponseKeys', () => { - const mockResponse = createMockSchemaResponse('response-1', RevisionReviewStates.Approved); - mockResponse.updatedResponseKeys = ['key1', 'key2', 'key3']; fixture.componentRef.setInput('schemaBlocks', []); fixture.componentRef.setInput('schemaResponse', mockResponse); fixture.detectChanges(); - expect(component.updatedFields()).toEqual(['key1', 'key2', 'key3']); + expect(fixture.componentInstance.updatedFields()).toEqual(['key1', 'key2']); }); - it('should return empty array when schemaResponse is null', () => { + it('should return empty updatedFields when schemaResponse is null', () => { + const fixture = TestBed.createComponent(RegistryBlocksSectionComponent); fixture.componentRef.setInput('schemaBlocks', []); fixture.componentRef.setInput('schemaResponse', null); fixture.detectChanges(); - expect(component.updatedFields()).toEqual([]); - }); - - it('should handle single updatedResponseKey', () => { - const mockResponse = createMockSchemaResponse('response-1', RevisionReviewStates.Approved); - mockResponse.updatedResponseKeys = ['single-key']; - fixture.componentRef.setInput('schemaBlocks', []); - fixture.componentRef.setInput('schemaResponse', mockResponse); - fixture.detectChanges(); - - expect(component.updatedFields()).toEqual(['single-key']); - }); - - it('should initialize with all required inputs', () => { - const mockBlocks: PageSchema[] = [createMockPageSchema()]; - const mockResponse = createMockSchemaResponse('response-1', RevisionReviewStates.Approved); - - fixture.componentRef.setInput('schemaBlocks', mockBlocks); - fixture.componentRef.setInput('schemaResponse', mockResponse); - fixture.detectChanges(); - - expect(component.schemaBlocks()).toEqual(mockBlocks); - expect(component.schemaResponse()).toEqual(mockResponse); - expect(component.isLoading()).toBe(false); - }); - - it('should handle all inputs being set together', () => { - const mockBlocks: PageSchema[] = [createMockPageSchema()]; - const mockResponse = createMockSchemaResponse('response-1', RevisionReviewStates.Approved); - mockResponse.updatedResponseKeys = ['test-key']; - - fixture.componentRef.setInput('schemaBlocks', mockBlocks); - fixture.componentRef.setInput('schemaResponse', mockResponse); - fixture.componentRef.setInput('isLoading', true); - fixture.detectChanges(); - - expect(component.schemaBlocks()).toEqual(mockBlocks); - expect(component.schemaResponse()).toEqual(mockResponse); - expect(component.isLoading()).toBe(true); - expect(component.updatedFields()).toEqual(['test-key']); + expect(fixture.componentInstance.updatedFields()).toEqual([]); }); }); diff --git a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.html b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.html index e079f1667..480e1d155 100644 --- a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.html +++ b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.html @@ -67,7 +67,7 @@

- {{ requestForm.controls[ModerationDecisionFormControls.Comment].value.length }}/{{ decisionCommentLimit }} + {{ requestForm.controls[ModerationDecisionFormControls.Comment].value?.length }}/{{ decisionCommentLimit }}

- @if (commentExceedsLimit()) { - - {{ commentLengthErrorMessage() }} - - }
@if (preprint()?.reviewsState !== ReviewsState.Pending) { }
} diff --git a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts index 35b404aed..09eece290 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts @@ -1,9 +1,14 @@ +import { Store } from '@ngxs/store'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; -import { ReviewAction } from '@osf/features/moderation/models'; import { ProviderReviewsWorkflow, ReviewsState } from '@osf/features/preprints/enums'; -import { PreprintProviderDetails, PreprintRequest } from '@osf/features/preprints/models'; -import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; +import { + PreprintSelectors, + SubmitRequestsDecision, + SubmitReviewsDecision, +} from '@osf/features/preprints/store/preprint'; import { PreprintMakeDecisionComponent } from './preprint-make-decision.component'; @@ -11,35 +16,35 @@ 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 { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('PreprintMakeDecisionComponent', () => { let component: PreprintMakeDecisionComponent; let fixture: ComponentFixture; + let store: Store; + let router: Router; const mockPreprint = PREPRINT_MOCK; - const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; - const mockLatestAction: ReviewAction = REVIEW_ACTION_MOCK; - const mockWithdrawalRequest: PreprintRequest = PREPRINT_REQUEST_MOCK; + const mockProvider = PREPRINT_PROVIDER_DETAILS_MOCK; + const mockLatestAction = REVIEW_ACTION_MOCK; + const mockWithdrawalRequest = PREPRINT_REQUEST_MOCK; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PreprintMakeDecisionComponent, OSFTestingModule], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [PreprintMakeDecisionComponent], providers: [ + provideOSFCore(), provideMockStore({ - signals: [ - { - selector: PreprintSelectors.getPreprint, - value: mockPreprint, - }, - ], + signals: [{ selector: PreprintSelectors.getPreprint, value: mockPreprint }], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(PreprintMakeDecisionComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); + router = TestBed.inject(Router); fixture.componentRef.setInput('provider', mockProvider); fixture.componentRef.setInput('latestAction', mockLatestAction); @@ -47,14 +52,28 @@ describe('PreprintMakeDecisionComponent', () => { fixture.componentRef.setInput('isPendingWithdrawal', false); }); - it('should return preprint from store', () => { - const preprint = component.preprint(); - expect(preprint).toBe(mockPreprint); + it.each([ + { + caseName: 'pending preprint', + preprint: { ...mockPreprint, reviewsState: ReviewsState.Pending }, + isPendingWithdrawal: false, + expected: 'preprints.details.decision.makeDecision', + }, + { + caseName: 'pending withdrawal', + preprint: { ...mockPreprint, reviewsState: ReviewsState.Accepted }, + isPendingWithdrawal: true, + expected: 'preprints.details.decision.makeDecision', + }, + ])('should compute label decision button for $caseName', ({ preprint, isPendingWithdrawal, expected }) => { + fixture.componentRef.setInput('isPendingWithdrawal', isPendingWithdrawal); + jest.spyOn(component, 'preprint').mockReturnValue(preprint); + expect(component.labelDecisionButton()).toBe(expected); }); - it('should compute label decision button for pending preprint', () => { - const label = component.labelDecisionButton(); - expect(label).toBe('preprints.details.decision.makeDecision'); + it('should compute label decision button for withdrawn preprint', () => { + jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Withdrawn }); + expect(component.labelDecisionButton()).toBe('preprints.details.decision.withdrawalReason'); }); it('should compute make decision button disabled state', () => { @@ -62,19 +81,42 @@ describe('PreprintMakeDecisionComponent', () => { expect(disabled).toBe(false); }); - it('should compute label decision dialog header for pending preprint', () => { - const header = component.labelDecisionDialogHeader(); - expect(header).toBe('preprints.details.decision.header.submitDecision'); + it.each([ + { + caseName: 'pending preprint', + preprint: { ...mockPreprint, reviewsState: ReviewsState.Pending }, + isPendingWithdrawal: false, + expected: 'preprints.details.decision.header.submitDecision', + }, + { + caseName: 'pending withdrawal', + preprint: { ...mockPreprint, reviewsState: ReviewsState.Accepted }, + isPendingWithdrawal: true, + expected: 'preprints.details.decision.header.submitDecision', + }, + ])('should compute label decision dialog header for $caseName', ({ preprint, isPendingWithdrawal, expected }) => { + fixture.componentRef.setInput('isPendingWithdrawal', isPendingWithdrawal); + jest.spyOn(component, 'preprint').mockReturnValue(preprint); + expect(component.labelDecisionDialogHeader()).toBe(expected); }); - it('should compute label submit button for pending preprint', () => { - const label = component.labelSubmitButton(); - expect(label).toBe('preprints.details.decision.submitButton.submitDecision'); + it('should compute label decision dialog header for withdrawn preprint', () => { + jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Withdrawn }); + expect(component.labelDecisionDialogHeader()).toBe('preprints.details.decision.header.withdrawalReason'); }); - it('should compute accept option explanation for pre-moderation', () => { - const explanation = component.acceptOptionExplanation(); - expect(explanation).toBe('preprints.details.decision.accept.pre'); + it.each([ + { + workflow: ProviderReviewsWorkflow.PreModeration, + expected: 'preprints.details.decision.accept.pre', + }, + { + workflow: ProviderReviewsWorkflow.PostModeration, + expected: 'preprints.details.decision.accept.post', + }, + ])('should compute accept option explanation for workflow %s', ({ workflow, expected }) => { + fixture.componentRef.setInput('provider', { ...mockProvider, reviewsWorkflow: workflow }); + expect(component.acceptOptionExplanation()).toBe(expected); }); it('should compute reject option label for unpublished preprint', () => { @@ -82,49 +124,122 @@ describe('PreprintMakeDecisionComponent', () => { expect(label).toBe('preprints.details.decision.reject.label'); }); - it('should compute settings comments for private comments', () => { - const settings = component.settingsComments(); - expect(settings).toBeDefined(); + it('should compute basic derived settings and state flags', () => { + expect(component.settingsComments()).toBeDefined(); + expect(component.settingsNames()).toBeDefined(); + expect(component.settingsModeration()).toBeDefined(); + expect(typeof component.commentEdited()).toBe('boolean'); + expect(typeof component.commentExceedsLimit()).toBe('boolean'); + expect(typeof component.decisionChanged()).toBe('boolean'); }); - it('should compute settings names for named comments', () => { - const settings = component.settingsNames(); - expect(settings).toBeDefined(); + it('should return true when reviewer comment exceeds decision comment limit', () => { + component.reviewerComment.set('a'.repeat(component.decisionCommentLimit + 1)); + expect(component.commentExceedsLimit()).toBe(true); }); - it('should compute settings moderation for pre-moderation workflow', () => { - const settings = component.settingsModeration(); - expect(settings).toBeDefined(); + it('should have initial signal values', () => { + expect(component.dialogVisible).toBe(false); + expect(component.didValidate()).toBe(false); + expect(component.decision()).toBe(ReviewsState.Accepted); + expect(component.saving()).toBe(false); }); - it('should compute comment edited state', () => { - const edited = component.commentEdited(); - expect(typeof edited).toBe('boolean'); + it('should initialize decision and comments for pending preprint in constructor effect', () => { + fixture.componentRef.setInput('latestAction', { ...mockLatestAction, comment: 'Latest decision comment' }); + expect(component.decision()).toBe(ReviewsState.Accepted); + expect(component.initialReviewerComment()).toBeNull(); + expect(component.reviewerComment()).toBeNull(); }); - it('should compute comment exceeds limit state', () => { - const exceeds = component.commentExceedsLimit(); - expect(typeof exceeds).toBe('boolean'); + it('should initialize decision and comments for non-pending preprint in constructor effect', () => { + jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Rejected }); + fixture.componentRef.setInput('latestAction', { ...mockLatestAction, comment: 'Updated moderator comment' }); + fixture.detectChanges(); + expect(component.decision()).toBe(ReviewsState.Rejected); + expect(component.initialReviewerComment()).toBe('Updated moderator comment'); + expect(component.reviewerComment()).toBe('Updated moderator comment'); }); - it('should compute decision changed state', () => { - const changed = component.decisionChanged(); - expect(typeof changed).toBe('boolean'); + it('should return early in constructor effect when preprint is missing', () => { + component.decision.set(ReviewsState.Withdrawn); + component.initialReviewerComment.set('Initial'); + component.reviewerComment.set('Current'); + jest.spyOn(component, 'preprint').mockReturnValue(null); + fixture.componentRef.setInput('latestAction', { ...mockLatestAction, comment: 'Ignored comment' }); + expect(component.decision()).toBe(ReviewsState.Withdrawn); + expect(component.initialReviewerComment()).toBe('Initial'); + expect(component.reviewerComment()).toBe('Current'); }); - it('should have initial signal values', () => { - expect(component.dialogVisible).toBe(false); - expect(component.didValidate()).toBe(false); - expect(component.decision()).toBe(ReviewsState.Accepted); - expect(component.saving()).toBe(false); + it('should keep existing values when constructor effect receives null preprint', () => { + component.decision.set(ReviewsState.Rejected); + component.initialReviewerComment.set('Initial value'); + component.reviewerComment.set('Current value'); + jest.spyOn(component, 'preprint').mockReturnValue(null); + + fixture.componentRef.setInput('latestAction', { ...mockLatestAction, comment: 'Should not apply' }); + + expect(component.decision()).toBe(ReviewsState.Rejected); + expect(component.initialReviewerComment()).toBe('Initial value'); + expect(component.reviewerComment()).toBe('Current value'); + }); + + it('should set request decision justification from latest withdrawal request in constructor effect', () => { + component.requestDecisionJustification.set(null); + fixture.componentRef.setInput('latestWithdrawalRequest', { + ...mockWithdrawalRequest, + comment: 'Withdrawal reason', + }); + fixture.detectChanges(); + expect(component.requestDecisionJustification()).toBe('Withdrawal reason'); + }); + + it('should not set request decision justification when latest withdrawal request is missing', () => { + component.requestDecisionJustification.set('Existing value'); + fixture.componentRef.setInput('latestWithdrawalRequest', null); + expect(component.requestDecisionJustification()).toBe('Existing value'); + }); + + it('should keep existing justification when constructor effect receives null withdrawal request', () => { + component.requestDecisionJustification.set('Persisted justification'); + + fixture.componentRef.setInput('latestWithdrawalRequest', null); + + expect(component.requestDecisionJustification()).toBe('Persisted justification'); }); - it('should handle request decision toggled', () => { - expect(() => component.requestDecisionToggled()).not.toThrow(); + it('should set justification from latest withdrawal request when toggled to accepted', () => { + fixture.componentRef.setInput('isPendingWithdrawal', true); + component.decision.set(ReviewsState.Accepted); + component.requestDecisionJustification.set(null); + + component.requestDecisionToggled(); + + expect(component.requestDecisionJustification()).toBe(mockWithdrawalRequest.comment); + }); + + it('should clear justification when toggled to rejected', () => { + fixture.componentRef.setInput('isPendingWithdrawal', true); + component.requestDecisionJustification.set('Some justification'); + component.decision.set(ReviewsState.Rejected); + + component.requestDecisionToggled(); + + expect(component.requestDecisionJustification()).toBeNull(); }); - it('should handle cancel', () => { - expect(() => component.cancel()).not.toThrow(); + it('should reset local dialog state on cancel', () => { + component.dialogVisible = true; + component.decision.set(ReviewsState.Rejected); + component.initialReviewerComment.set('Initial'); + component.reviewerComment.set('Changed'); + + component.cancel(); + + expect(component.dialogVisible).toBe(false); + expect(component.decision()).toBe(mockPreprint.reviewsState); + expect(component.reviewerComment()).toBe('Initial'); }); it('should compute label submit button when decision changed', () => { @@ -145,6 +260,12 @@ describe('PreprintMakeDecisionComponent', () => { expect(label).toBe('preprints.details.decision.submitButton.updateComment'); }); + it('should compute label submit button as submit decision for pending withdrawal', () => { + jest.spyOn(component, 'isPendingWithdrawal').mockReturnValue(true); + jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Accepted }); + expect(component.labelSubmitButton()).toBe('preprints.details.decision.submitButton.submitDecision'); + }); + it('should compute submit button disabled when neither decision changed nor comment edited', () => { jest.spyOn(component, 'decisionChanged').mockReturnValue(false); jest.spyOn(component, 'commentEdited').mockReturnValue(false); @@ -152,13 +273,6 @@ describe('PreprintMakeDecisionComponent', () => { expect(disabled).toBe(true); }); - it('should compute accept option explanation for post-moderation', () => { - const postModerationProvider = { ...mockProvider, reviewsWorkflow: ProviderReviewsWorkflow.PostModeration }; - fixture.componentRef.setInput('provider', postModerationProvider); - const explanation = component.acceptOptionExplanation(); - expect(explanation).toBe('preprints.details.decision.accept.post'); - }); - it('should compute label request decision justification for accepted decision', () => { component.decision.set(ReviewsState.Accepted); const label = component.labelRequestDecisionJustification(); @@ -184,6 +298,12 @@ describe('PreprintMakeDecisionComponent', () => { expect(explanation).toBeDefined(); }); + it('should fallback reject option explanation when workflow is null', () => { + fixture.componentRef.setInput('provider', { ...mockProvider, reviewsWorkflow: null }); + const explanation = component.rejectOptionExplanation(); + expect(explanation).toBe('preprints.details.decision.withdrawn.post'); + }); + it('should compute reject radio button value for published preprint', () => { jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, isPublished: true }); const value = component.rejectRadioButtonValue(); @@ -191,6 +311,143 @@ describe('PreprintMakeDecisionComponent', () => { }); it('should handle submit method', () => { + (store.dispatch as jest.Mock).mockClear(); expect(() => component.submit()).not.toThrow(); + expect(store.dispatch).toHaveBeenCalled(); + }); + + it('should not submit when preprint is missing', () => { + (store.dispatch as jest.Mock).mockClear(); + jest.spyOn(component, 'preprint').mockReturnValue(null); + component.submit(); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(component.saving()).toBe(false); + }); + + it('should validate withdrawal rejection justification with trim-aware required check', () => { + fixture.componentRef.setInput('isPendingWithdrawal', true); + component.decision.set(ReviewsState.Rejected); + component.requestDecisionJustification.set(' '); + (store.dispatch as jest.Mock).mockClear(); + + component.submit(); + + expect(component.didValidate()).toBe(true); + expect(component.requestDecisionJustificationErrorMessage()).toBe( + 'preprints.details.decision.justificationRequiredError' + ); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should return justification min-length error when justification is too short', () => { + fixture.componentRef.setInput('isPendingWithdrawal', true); + component.decision.set(ReviewsState.Rejected); + component.requestDecisionJustification.set('a'); + expect(component.requestDecisionJustificationErrorMessage()).toBe( + 'preprints.details.decision.justificationLengthError' + ); + }); + + it('should submit pending withdrawal decision and navigate to withdrawals', () => { + const navigateSpy = jest.spyOn(router, 'navigate').mockResolvedValue(true); + fixture.componentRef.setInput('isPendingWithdrawal', true); + fixture.componentRef.setInput('latestWithdrawalRequest', { ...mockWithdrawalRequest, id: 'request-123' }); + component.decision.set(ReviewsState.Accepted); + component.requestDecisionJustification.set(' valid justification '); + (store.dispatch as jest.Mock).mockClear(); + + component.submit(); + + expect(store.dispatch).toHaveBeenCalledWith( + new SubmitRequestsDecision('request-123', 'accept', 'valid justification') + ); + expect(navigateSpy).toHaveBeenCalledWith(['preprints', mockProvider.id, 'moderation', 'withdrawals']); + expect(component.saving()).toBe(false); + }); + + it('should submit edit_comment trigger when only comment changed on non-pending decision', () => { + jest.spyOn(component, 'preprint').mockReturnValue({ + ...mockPreprint, + reviewsState: ReviewsState.Accepted, + isPublished: false, + }); + fixture.componentRef.setInput('isPendingWithdrawal', false); + component.decision.set(ReviewsState.Accepted); + component.initialReviewerComment.set('Old comment'); + component.reviewerComment.set('New comment'); + (store.dispatch as jest.Mock).mockClear(); + + component.submit(); + + expect(store.dispatch).toHaveBeenCalledWith(new SubmitReviewsDecision('edit_comment', 'New comment')); + }); + + it('should submit reject trigger for published preprint with pending withdrawal and rejected decision', () => { + fixture.componentRef.setInput('isPendingWithdrawal', true); + fixture.componentRef.setInput('latestWithdrawalRequest', { ...mockWithdrawalRequest, id: 'request-456' }); + jest.spyOn(component, 'preprint').mockReturnValue({ + ...mockPreprint, + reviewsState: ReviewsState.Accepted, + isPublished: true, + }); + component.decision.set(ReviewsState.Rejected); + component.requestDecisionJustification.set('Valid rejection reason'); + (store.dispatch as jest.Mock).mockClear(); + + component.submit(); + + expect(store.dispatch).toHaveBeenCalledWith( + new SubmitRequestsDecision('request-456', 'reject', 'Valid rejection reason') + ); + }); + + it('should submit withdraw trigger for published preprint without pending withdrawal and rejected decision', () => { + fixture.componentRef.setInput('isPendingWithdrawal', false); + jest.spyOn(component, 'preprint').mockReturnValue({ + ...mockPreprint, + reviewsState: ReviewsState.Accepted, + isPublished: true, + }); + component.decision.set(ReviewsState.Rejected); + component.initialReviewerComment.set('Original'); + component.reviewerComment.set('Updated rejection note'); + (store.dispatch as jest.Mock).mockClear(); + + component.submit(); + + expect(store.dispatch).toHaveBeenCalledWith(new SubmitReviewsDecision('withdraw', 'Updated rejection note')); + }); + + it('should not dispatch pending-withdrawal decision when latest withdrawal request is missing', () => { + fixture.componentRef.setInput('isPendingWithdrawal', true); + fixture.componentRef.setInput('latestWithdrawalRequest', null); + component.decision.set(ReviewsState.Accepted); + component.requestDecisionJustification.set('Valid justification'); + (store.dispatch as jest.Mock).mockClear(); + + component.submit(); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(component.saving()).toBe(false); + }); + + it('should not reset decision in cancel when preprint is missing', () => { + component.decision.set(ReviewsState.Rejected); + component.initialReviewerComment.set('Initial'); + component.reviewerComment.set('Changed'); + jest.spyOn(component, 'preprint').mockReturnValue(null); + + component.cancel(); + + expect(component.decision()).toBe(ReviewsState.Rejected); + expect(component.reviewerComment()).toBe('Initial'); + }); + + it('should return early in requestDecisionToggled when not pending withdrawal', () => { + fixture.componentRef.setInput('isPendingWithdrawal', false); + component.decision.set(ReviewsState.Rejected); + component.requestDecisionJustification.set('Existing value'); + component.requestDecisionToggled(); + expect(component.requestDecisionJustification()).toBe('Existing value'); }); }); diff --git a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts index 65ffaa1f9..dd854f4f4 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts @@ -9,6 +9,8 @@ import { RadioButton } from 'primeng/radiobutton'; import { Textarea } from 'primeng/textarea'; import { Tooltip } from 'primeng/tooltip'; +import { finalize } from 'rxjs'; + import { TitleCasePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; @@ -28,7 +30,7 @@ import { StringOrNull } from '@osf/shared/helpers/types.helper'; @Component({ selector: 'osf-preprint-make-decision', - imports: [Button, TranslatePipe, TitleCasePipe, Dialog, Tooltip, RadioButton, FormsModule, Textarea, Message], + imports: [Button, Dialog, Message, RadioButton, Textarea, Tooltip, FormsModule, TranslatePipe, TitleCasePipe], templateUrl: './preprint-make-decision.component.html', styleUrl: './preprint-make-decision.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -36,18 +38,18 @@ import { StringOrNull } from '@osf/shared/helpers/types.helper'; export class PreprintMakeDecisionComponent { private readonly translateService = inject(TranslateService); private readonly router = inject(Router); + private readonly actions = createDispatchMap({ submitReviewsDecision: SubmitReviewsDecision, submitRequestsDecision: SubmitRequestsDecision, }); - readonly ReviewsState = ReviewsState; + readonly provider = input.required(); + readonly latestAction = input.required(); + readonly latestWithdrawalRequest = input.required(); + readonly isPendingWithdrawal = input.required(); - preprint = select(PreprintSelectors.getPreprint); - provider = input.required(); - latestAction = input.required(); - latestWithdrawalRequest = input.required(); - isPendingWithdrawal = input.required(); + readonly preprint = select(PreprintSelectors.getPreprint); dialogVisible = false; didValidate = signal(false); @@ -56,7 +58,9 @@ export class PreprintMakeDecisionComponent { reviewerComment = signal(null); requestDecisionJustification = signal(null); saving = signal(false); - decisionCommentLimit = InputLimits.decisionComment.maxLength; + + readonly decisionCommentLimit = InputLimits.decisionComment.maxLength; + readonly ReviewsState = ReviewsState; labelDecisionButton = computed(() => { const preprint = this.preprint(); @@ -92,15 +96,17 @@ export class PreprintMakeDecisionComponent { }); labelSubmitButton = computed(() => { - if (this.isPendingWithdrawal()) { - return 'preprints.details.decision.submitButton.submitDecision'; - } else if (this.preprint()?.reviewsState === ReviewsState.Pending) { + const preprint = this.preprint(); + const reviewsState = preprint?.reviewsState; + + if (this.isPendingWithdrawal() || reviewsState === ReviewsState.Pending) { return 'preprints.details.decision.submitButton.submitDecision'; - } else if (this.decisionChanged()) { - return 'preprints.details.decision.submitButton.modifyDecision'; - } else if (this.commentEdited()) { + } + + if (this.commentEdited() && !this.decisionChanged()) { return 'preprints.details.decision.submitButton.updateComment'; } + return 'preprints.details.decision.submitButton.modifyDecision'; }); @@ -110,25 +116,22 @@ export class PreprintMakeDecisionComponent { acceptOptionExplanation = computed(() => { const reviewsWorkflow = this.provider().reviewsWorkflow; - if (reviewsWorkflow === ProviderReviewsWorkflow.PreModeration) { - return 'preprints.details.decision.accept.pre'; - } else if (reviewsWorkflow === ProviderReviewsWorkflow.PostModeration) { + + if (reviewsWorkflow === ProviderReviewsWorkflow.PostModeration) { return 'preprints.details.decision.accept.post'; } return 'preprints.details.decision.accept.pre'; }); - rejectOptionLabel = computed(() => { - return this.preprint()?.isPublished + rejectOptionLabel = computed(() => + this.preprint()?.isPublished ? 'preprints.details.decision.withdrawn.label' - : 'preprints.details.decision.reject.label'; - }); + : 'preprints.details.decision.reject.label' + ); labelRequestDecisionJustification = computed(() => { - if (this.decision() === ReviewsState.Accepted) { - return 'preprints.details.decision.withdrawalJustification'; - } else if (this.decision() === ReviewsState.Rejected) { + if (this.decision() === ReviewsState.Rejected) { return 'preprints.details.decision.denialJustification'; } @@ -136,16 +139,19 @@ export class PreprintMakeDecisionComponent { }); rejectOptionExplanation = computed(() => { - const reviewsWorkflow = this.provider().reviewsWorkflow; - if (reviewsWorkflow === ProviderReviewsWorkflow.PreModeration) { - if (this.preprint()?.reviewsState === ReviewsState.Accepted) { - return 'preprints.details.decision.approve.explanation'; - } else { - return decisionExplanation.reject[reviewsWorkflow]; - } - } else { - return decisionExplanation.withdrawn[reviewsWorkflow!]; + const provider = this.provider(); + const reviewsWorkflow = provider.reviewsWorkflow; + const isPreModeration = reviewsWorkflow === ProviderReviewsWorkflow.PreModeration; + + if (isPreModeration && this.preprint()?.reviewsState === ReviewsState.Accepted) { + return 'preprints.details.decision.approve.explanation'; + } + + if (isPreModeration) { + return decisionExplanation.reject[reviewsWorkflow]; } + + return decisionExplanation.withdrawn[ProviderReviewsWorkflow.PostModeration]; }); rejectRadioButtonValue = computed(() => @@ -175,22 +181,18 @@ export class PreprintMakeDecisionComponent { return comment.length > this.decisionCommentLimit; }); - commentLengthErrorMessage = computed(() => - this.translateService.instant('preprints.details.decision.commentLengthError', { - limit: this.decisionCommentLimit, - length: this.reviewerComment()!.length, - }) - ); - requestDecisionJustificationErrorMessage = computed(() => { const justification = this.requestDecisionJustification(); const minLength = formInputLimits.requestDecisionJustification.minLength; + const trimmedJustification = justification?.trim() ?? ''; + + if (!trimmedJustification) { + return this.translateService.instant('preprints.details.decision.justificationRequiredError'); + } - if (!justification) return this.translateService.instant('preprints.details.decision.justificationRequiredError'); - if (justification.length < minLength) - return this.translateService.instant('preprints.details.decision.justificationLengthError', { - minLength, - }); + if (trimmedJustification.length < minLength) { + return this.translateService.instant('preprints.details.decision.justificationLengthError', { minLength }); + } return null; }); @@ -200,17 +202,20 @@ export class PreprintMakeDecisionComponent { constructor() { effect(() => { const preprint = this.preprint(); + + if (!preprint) { + return; + } + const latestAction = this.latestAction(); - if (preprint && latestAction) { - if (preprint.reviewsState === ReviewsState.Pending) { - this.decision.set(ReviewsState.Accepted); - this.initialReviewerComment.set(null); - this.reviewerComment.set(null); - } else { - this.decision.set(preprint.reviewsState); - this.initialReviewerComment.set(latestAction?.comment); - this.reviewerComment.set(latestAction?.comment); - } + if (preprint.reviewsState === ReviewsState.Pending) { + this.decision.set(ReviewsState.Accepted); + this.initialReviewerComment.set(null); + this.reviewerComment.set(null); + } else { + this.decision.set(preprint.reviewsState); + this.initialReviewerComment.set(latestAction?.comment ?? null); + this.reviewerComment.set(latestAction?.comment ?? null); } }); @@ -223,8 +228,10 @@ export class PreprintMakeDecisionComponent { } submit() { - // don't remove comments - const preprint = this.preprint()!; + // Don't remove comments + const preprint = this.preprint(); + if (!preprint) return; + let trigger = ''; if (preprint.reviewsState !== ReviewsState.Pending && this.commentEdited() && !this.decisionChanged()) { // If the submission is not pending, @@ -256,6 +263,7 @@ export class PreprintMakeDecisionComponent { } let comment: StringOrNull = ''; + if (this.isPendingWithdrawal()) { if (trigger === 'reject') { this.didValidate.set(true); @@ -270,26 +278,32 @@ export class PreprintMakeDecisionComponent { } this.saving.set(true); + if (this.isPendingWithdrawal()) { - this.actions.submitRequestsDecision(this.latestWithdrawalRequest()!.id, trigger, comment).subscribe({ - next: () => { - this.saving.set(false); - this.router.navigate(['preprints', this.provider().id, 'moderation', 'withdrawals']); - }, - error: () => { - this.saving.set(false); - }, - }); + const latestWithdrawalRequest = this.latestWithdrawalRequest(); + + if (!latestWithdrawalRequest) { + this.saving.set(false); + return; + } + + this.actions + .submitRequestsDecision(latestWithdrawalRequest.id, trigger, comment) + .pipe(finalize(() => this.saving.set(false))) + .subscribe({ + next: () => { + this.router.navigate(['preprints', this.provider().id, 'moderation', 'withdrawals']); + }, + }); } else { - this.actions.submitReviewsDecision(trigger, comment).subscribe({ - next: () => { - this.saving.set(false); - this.router.navigate(['preprints', this.provider().id, 'moderation', 'submissions']); - }, - error: () => { - this.saving.set(false); - }, - }); + this.actions + .submitReviewsDecision(trigger, comment) + .pipe(finalize(() => this.saving.set(false))) + .subscribe({ + next: () => { + this.router.navigate(['preprints', this.provider().id, 'moderation', 'submissions']); + }, + }); } } @@ -307,7 +321,12 @@ export class PreprintMakeDecisionComponent { cancel() { this.dialogVisible = false; - this.decision.set(this.preprint()!.reviewsState); + const preprint = this.preprint(); + + if (preprint) { + this.decision.set(preprint.reviewsState); + } + this.reviewerComment.set(this.initialReviewerComment()); } } diff --git a/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.html b/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.html index 6ee03c0f4..fed0278c5 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.html +++ b/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.html @@ -1,3 +1,5 @@ +@let metricsValue = metrics(); + @if (isLoading()) {
@@ -5,11 +7,11 @@
- } @else if (metrics()) { + } @else if (metricsValue) {
- {{ 'preprints.details.share.views' | translate }}: {{ metrics()!.views }} + {{ 'preprints.details.share.views' | translate }}: {{ metricsValue.views }} | - {{ 'preprints.details.share.downloads' | translate }}: {{ metrics()!.downloads }} + {{ 'preprints.details.share.downloads' | translate }}: {{ metricsValue.downloads }}
}
diff --git a/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.spec.ts index 4b9034cc7..09b58dbdf 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.spec.ts @@ -1,15 +1,20 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PreprintMetrics } from '@osf/features/preprints/models'; + import { PreprintMetricsInfoComponent } from './preprint-metrics-info.component'; +import { provideOSFCore } from '@testing/osf.testing.provider'; + describe('PreprintMetricsInfoComponent', () => { let component: PreprintMetricsInfoComponent; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ + beforeEach(() => { + TestBed.configureTestingModule({ imports: [PreprintMetricsInfoComponent], - }).compileComponents(); + providers: [provideOSFCore()], + }); fixture = TestBed.createComponent(PreprintMetricsInfoComponent); component = fixture.componentInstance; @@ -19,4 +24,48 @@ describe('PreprintMetricsInfoComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should show loading skeletons when isLoading is true', () => { + fixture.componentRef.setInput('isLoading', true); + fixture.componentRef.setInput('metrics', null); + fixture.detectChanges(); + + const skeletons = fixture.nativeElement.querySelectorAll('p-skeleton'); + expect(skeletons.length).toBe(3); + }); + + it('should show metrics when loading is false and metrics are provided', () => { + const metrics: PreprintMetrics = { views: 123, downloads: 45 }; + fixture.componentRef.setInput('isLoading', false); + fixture.componentRef.setInput('metrics', metrics); + fixture.detectChanges(); + + const text = fixture.nativeElement.textContent; + expect(text).toContain('123'); + expect(text).toContain('45'); + }); + + it('should render neither skeleton nor metrics when loading is false and metrics are null', () => { + fixture.componentRef.setInput('isLoading', false); + fixture.componentRef.setInput('metrics', null); + fixture.detectChanges(); + + const skeletons = fixture.nativeElement.querySelectorAll('p-skeleton'); + expect(skeletons.length).toBe(0); + const metricsBlock = fixture.nativeElement.querySelector('.font-bold'); + expect(metricsBlock).toBeNull(); + }); + + it('should prioritize loading state over metrics display', () => { + const metrics: PreprintMetrics = { views: 321, downloads: 54 }; + fixture.componentRef.setInput('isLoading', true); + fixture.componentRef.setInput('metrics', metrics); + fixture.detectChanges(); + + const skeletons = fixture.nativeElement.querySelectorAll('p-skeleton'); + expect(skeletons.length).toBe(3); + const text = fixture.nativeElement.textContent; + expect(text).not.toContain('321'); + expect(text).not.toContain('54'); + }); }); diff --git a/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.ts b/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.ts index ce1ee2b84..ceb4158b6 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.ts @@ -15,6 +15,6 @@ import { PreprintMetrics } from '@osf/features/preprints/models'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintMetricsInfoComponent { - metrics = input(); - isLoading = input(false); + readonly metrics = input(); + readonly isLoading = input(false); } diff --git a/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.html b/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.html index a9423933d..8fef42439 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.html +++ b/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.html @@ -1,6 +1,7 @@ +@let preprintValue = preprint(); + - @if (preprint()) { - @let preprintValue = preprint()!; + @if (preprintValue) {
@if (preprintValue.withdrawalJustification) {
diff --git a/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.scss b/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.scss index 5722bc8e5..e69de29bb 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.scss +++ b/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.scss @@ -1,3 +0,0 @@ -.white-space-pre-line { - white-space: pre-line; -} diff --git a/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.spec.ts index 336cfc9b5..0fab7b7ff 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.spec.ts @@ -1,14 +1,22 @@ -import { MockComponents, MockPipes } from 'ng-mocks'; +import { Store } from '@ngxs/store'; +import { MockComponents, MockProvider } from 'ng-mocks'; + +import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; 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 { InterpolatePipe } from '@osf/shared/pipes/interpolate.pipe'; -import { ContributorsSelectors } from '@osf/shared/stores/contributors'; -import { SubjectsSelectors } from '@osf/shared/stores/subjects'; +import { ResourceType } from '@shared/enums/resource-type.enum'; +import { + ContributorsSelectors, + GetBibliographicContributors, + LoadMoreBibliographicContributors, + ResetContributorsState, +} from '@shared/stores/contributors'; +import { FetchSelectedSubjects, SubjectsSelectors } from '@shared/stores/subjects'; import { PreprintDoiSectionComponent } from '../preprint-doi-section/preprint-doi-section.component'; @@ -18,106 +26,140 @@ import { MOCK_CONTRIBUTOR } from '@testing/mocks/contributors.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 { TranslationServiceMock } from '@testing/mocks/translation.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { MockComponentWithSignal } from '@testing/providers/component-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; describe('PreprintTombstoneComponent', () => { let component: PreprintTombstoneComponent; let fixture: ComponentFixture; + let store: Store; + let mockRouter: RouterMockType; const mockPreprint = PREPRINT_MOCK; const mockProvider = PREPRINT_PROVIDER_DETAILS_MOCK; const mockContributors = [MOCK_CONTRIBUTOR]; const mockSubjects = SUBJECTS_MOCK; - beforeEach(async () => { - await TestBed.configureTestingModule({ + interface SetupOverrides extends BaseSetupOverrides { + platformId?: 'browser' | 'server'; + } + + function setup(overrides: SetupOverrides = {}) { + mockRouter = RouterMockBuilder.create().build(); + + TestBed.configureTestingModule({ imports: [ PreprintTombstoneComponent, - OSFTestingModule, - ...MockComponents( - PreprintDoiSectionComponent, - TruncatedTextComponent, - ContributorsListComponent, - LicenseDisplayComponent - ), - MockPipes(InterpolatePipe), + ...MockComponents(ContributorsListComponent, LicenseDisplayComponent, PreprintDoiSectionComponent), + MockComponentWithSignal('osf-truncated-text'), ], providers: [ - TranslationServiceMock, + provideOSFCore(), + MockProvider(PLATFORM_ID, overrides.platformId ?? 'browser'), + MockProvider(Router, mockRouter), provideMockStore({ - signals: [ - { - selector: PreprintSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintSelectors.isPreprintLoading, - value: false, - }, - { - selector: ContributorsSelectors.getBibliographicContributors, - value: mockContributors, - }, - { - selector: ContributorsSelectors.isBibliographicContributorsLoading, - value: false, - }, - { - selector: ContributorsSelectors.hasMoreBibliographicContributors, - value: false, - }, - { - selector: SubjectsSelectors.getSelectedSubjects, - value: mockSubjects, - }, - { - selector: SubjectsSelectors.areSelectedSubjectsLoading, - value: false, - }, - ], + signals: mergeSignalOverrides( + [ + { selector: PreprintSelectors.getPreprint, value: mockPreprint }, + { selector: PreprintSelectors.isPreprintLoading, value: false }, + { selector: ContributorsSelectors.getBibliographicContributors, value: mockContributors }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, + { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false }, + { selector: SubjectsSelectors.getSelectedSubjects, value: mockSubjects }, + { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false }, + ], + overrides.selectorOverrides + ), }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(PreprintTombstoneComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.componentRef.setInput('preprintProvider', mockProvider); - }); + (store.dispatch as jest.Mock).mockClear(); + } - it('should compute license from preprint', () => { - const license = component.license(); - expect(license).toBe(mockPreprint.embeddedLicense); + it('should create', () => { + setup(); + expect(component).toBeTruthy(); }); - it('should return null license when no preprint', () => { - jest.spyOn(component, 'preprint').mockReturnValue(null); - const license = component.license(); - expect(license).toBeNull(); + it('should expose selectors and computed values', () => { + setup(); + expect(component.preprint()).toBe(mockPreprint); + expect(component.bibliographicContributors()).toBe(mockContributors); + expect(component.subjects()).toBe(mockSubjects); + expect(component.license()).toBe(mockPreprint.embeddedLicense); + expect(component.licenseOptionsRecord()).toEqual(mockPreprint.licenseOptions); }); - it('should compute license options record from preprint', () => { - const licenseOptionsRecord = component.licenseOptionsRecord(); - expect(licenseOptionsRecord).toEqual(mockPreprint.licenseOptions); + it('should fallback computed values when preprint data is missing', () => { + setup({ selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: null }] }); + expect(component.license()).toBeNull(); + expect(component.licenseOptionsRecord()).toEqual({}); }); - it('should return empty object when no license options', () => { - const preprintWithoutOptions = { ...mockPreprint, licenseOptions: null }; - jest.spyOn(component, 'preprint').mockReturnValue(preprintWithoutOptions); - const licenseOptionsRecord = component.licenseOptionsRecord(); - expect(licenseOptionsRecord).toEqual({}); - }); - - it('should handle preprint provider input', () => { - const provider = component.preprintProvider(); - expect(provider).toBe(mockProvider); + it('should expose preprint provider input and skeleton data', () => { + setup(); + expect(component.preprintProvider()).toBe(mockProvider); + expect(component.skeletonData).toHaveLength(6); }); it('should emit preprintVersionSelected when version is selected', () => { + setup(); const emitSpy = jest.spyOn(component.preprintVersionSelected, 'emit'); component.preprintVersionSelected.emit('version-1'); expect(emitSpy).toHaveBeenCalledWith('version-1'); }); + + it('should dispatch contributor and subject fetch actions when preprint id exists', () => { + setup(); + fixture.detectChanges(); + TestBed.flushEffects(); + expect(store.dispatch).toHaveBeenCalledWith( + new GetBibliographicContributors(mockPreprint.id, ResourceType.Preprint) + ); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSelectedSubjects(mockPreprint.id, ResourceType.Preprint)); + }); + + it('should not dispatch contributor and subject fetch actions when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: undefined }], + }); + fixture.detectChanges(); + TestBed.flushEffects(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetBibliographicContributors)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchSelectedSubjects)); + }); + + it('should dispatch load more contributors action', () => { + setup(); + component.loadMoreContributors(); + expect(store.dispatch).toHaveBeenCalledWith( + new LoadMoreBibliographicContributors(mockPreprint.id, ResourceType.Preprint) + ); + }); + + it('should navigate on tag click', () => { + setup(); + component.tagClicked('biology'); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/search'], { queryParams: { search: 'biology' } }); + }); + + it('should reset contributors state on destroy', () => { + setup(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(new ResetContributorsState()); + }); + + it('should not reset contributors state on destroy when not in browser', () => { + setup({ platformId: 'server' }); + component.ngOnDestroy(); + expect(store.dispatch).not.toHaveBeenCalledWith(new ResetContributorsState()); + }); }); diff --git a/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.ts b/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.ts index 2023c666e..b1f94e7b8 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.ts @@ -6,8 +6,18 @@ import { Card } from 'primeng/card'; import { Skeleton } from 'primeng/skeleton'; import { Tag } from 'primeng/tag'; -import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, input, OnDestroy, output } from '@angular/core'; +import { DatePipe, isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + OnDestroy, + output, + PLATFORM_ID, +} from '@angular/core'; import { Router } from '@angular/router'; import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums'; @@ -31,67 +41,64 @@ import { PreprintDoiSectionComponent } from '../preprint-doi-section/preprint-do selector: 'osf-preprint-tombstone', imports: [ Card, - PreprintDoiSectionComponent, - Skeleton, - TranslatePipe, - TruncatedTextComponent, Tag, - DatePipe, + Skeleton, ContributorsListComponent, LicenseDisplayComponent, + PreprintDoiSectionComponent, + TruncatedTextComponent, + DatePipe, + TranslatePipe, ], templateUrl: './preprint-tombstone.component.html', styleUrl: './preprint-tombstone.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintTombstoneComponent implements OnDestroy { - readonly ApplicabilityStatus = ApplicabilityStatus; - readonly PreregLinkInfo = PreregLinkInfo; + private readonly router = inject(Router); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); - private actions = createDispatchMap({ + readonly preprintProvider = input.required(); + readonly preprintVersionSelected = output(); + + private readonly actions = createDispatchMap({ getBibliographicContributors: GetBibliographicContributors, resetContributorsState: ResetContributorsState, fetchPreprintById: FetchPreprintDetails, fetchSubjects: FetchSelectedSubjects, loadMoreBibliographicContributors: LoadMoreBibliographicContributors, }); - private router = inject(Router); - - preprintVersionSelected = output(); - preprintProvider = input.required(); + readonly preprint = select(PreprintSelectors.getPreprint); + readonly isPreprintLoading = select(PreprintSelectors.isPreprintLoading); + readonly bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); + readonly areContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); + readonly hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); + readonly subjects = select(SubjectsSelectors.getSelectedSubjects); + readonly areSelectedSubjectsLoading = select(SubjectsSelectors.areSelectedSubjectsLoading); - preprint = select(PreprintSelectors.getPreprint); - isPreprintLoading = select(PreprintSelectors.isPreprintLoading); - - bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); - areContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); - hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); - subjects = select(SubjectsSelectors.getSelectedSubjects); - areSelectedSubjectsLoading = select(SubjectsSelectors.areSelectedSubjectsLoading); - - license = computed(() => { - const preprint = this.preprint(); - if (!preprint) return null; - return preprint.embeddedLicense; - }); + readonly ApplicabilityStatus = ApplicabilityStatus; + readonly PreregLinkInfo = PreregLinkInfo; - licenseOptionsRecord = computed(() => (this.preprint()?.licenseOptions ?? {}) as Record); + readonly license = computed(() => this.preprint()?.embeddedLicense ?? null); + readonly licenseOptionsRecord = computed(() => (this.preprint()?.licenseOptions ?? {}) as Record); - skeletonData = Array.from({ length: 6 }, () => null); + readonly skeletonData = new Array(6).fill(null); constructor() { effect(() => { - const preprint = this.preprint(); - if (!preprint) return; + const preprintId = this.preprint()?.id; + if (!preprintId) return; - this.actions.getBibliographicContributors(this.preprint()?.id, ResourceType.Preprint); - this.actions.fetchSubjects(this.preprint()!.id, ResourceType.Preprint); + this.actions.getBibliographicContributors(preprintId, ResourceType.Preprint); + this.actions.fetchSubjects(preprintId, ResourceType.Preprint); }); } ngOnDestroy(): void { - this.actions.resetContributorsState(); + if (this.isBrowser) { + this.actions.resetContributorsState(); + } } tagClicked(tag: string) { diff --git a/src/app/features/preprints/components/preprint-details/preprint-warning-banner/preprint-warning-banner.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-warning-banner/preprint-warning-banner.component.spec.ts index 759457a40..32019eae8 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-warning-banner/preprint-warning-banner.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-warning-banner/preprint-warning-banner.component.spec.ts @@ -1,19 +1,18 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { PreprintWarningBannerComponent } from './preprint-warning-banner.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('PreprintWarningBannerComponent', () => { let component: PreprintWarningBannerComponent; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PreprintWarningBannerComponent, OSFTestingModule], - providers: [provideNoopAnimations()], - }).compileComponents(); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [PreprintWarningBannerComponent], + providers: [provideOSFCore()], + }); fixture = TestBed.createComponent(PreprintWarningBannerComponent); component = fixture.componentInstance; @@ -23,14 +22,4 @@ describe('PreprintWarningBannerComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); - - it('should show correct icon and text', () => { - const banner: HTMLElement = fixture.nativeElement; - const icon = banner.querySelector('i'); - const text = banner.querySelector('span'); - expect(icon).toBeDefined(); - expect(text).toBeDefined(); - expect(icon?.getAttribute('ng-reflect-ng-class')).toEqual('fas fa-triangle-exclamation'); - expect(text?.textContent?.trim()).toEqual('preprints.details.warningBanner'); - }); }); diff --git a/src/app/features/preprints/components/preprint-details/preprint-warning-banner/preprint-warning-banner.component.ts b/src/app/features/preprints/components/preprint-details/preprint-warning-banner/preprint-warning-banner.component.ts index fc55f6f4c..b6b19d5e5 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-warning-banner/preprint-warning-banner.component.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-warning-banner/preprint-warning-banner.component.ts @@ -6,7 +6,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; @Component({ selector: 'osf-preprint-warning-banner', - imports: [TranslatePipe, Message], + imports: [Message, TranslatePipe], templateUrl: './preprint-warning-banner.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.html b/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.html index 6e5b9cd3f..602828a8b 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.html +++ b/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.html @@ -12,13 +12,16 @@ + + @let control = withdrawalJustificationFormControl; + @if (control.errors?.['required'] && (control.touched || control.dirty)) { {{ INPUT_VALIDATION_MESSAGES.required | translate }} @@ -37,16 +40,16 @@
diff --git a/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.spec.ts index 315c445c2..8d0cba677 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.spec.ts @@ -1,170 +1,162 @@ +import { Store } from '@ngxs/store'; + import { MockPipe, MockProvider } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { of, throwError } from 'rxjs'; + import { TitleCasePipe } from '@angular/common'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { formInputLimits } from '@osf/features/preprints/constants'; import { ProviderReviewsWorkflow, ReviewsState } from '@osf/features/preprints/enums'; import { PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/models'; +import { WithdrawPreprint } from '@osf/features/preprints/store/preprint'; import { PreprintWithdrawDialogComponent } from './preprint-withdraw-dialog.component'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.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 { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('PreprintWithdrawDialogComponent', () => { let component: PreprintWithdrawDialogComponent; let fixture: ComponentFixture; - let dialogRefMock: any; - let dialogConfigMock: any; + let dialogRefMock: { close: jest.Mock }; + let store: Store; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; const mockPreprint: PreprintModel = PREPRINT_MOCK; - beforeEach(async () => { - dialogRefMock = { - close: jest.fn(), - }; - dialogConfigMock = { - data: { provider: mockProvider, preprint: mockPreprint }, + interface SetupOverrides { + provider?: PreprintProviderDetails | undefined; + preprint?: PreprintModel | undefined; + } + + function setup(overrides: SetupOverrides = {}) { + const dialogConfigMock = { + data: { + provider: 'provider' in overrides ? overrides.provider : mockProvider, + preprint: 'preprint' in overrides ? overrides.preprint : mockPreprint, + }, }; - await TestBed.configureTestingModule({ - imports: [PreprintWithdrawDialogComponent, OSFTestingModule, MockPipe(TitleCasePipe)], + TestBed.configureTestingModule({ + imports: [PreprintWithdrawDialogComponent, MockPipe(TitleCasePipe)], providers: [ - MockProvider(DynamicDialogRef, dialogRefMock), + provideOSFCore(), + provideDynamicDialogRefMock(), MockProvider(DynamicDialogConfig, dialogConfigMock), - provideMockStore({ - signals: [], - }), + provideMockStore(), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(PreprintWithdrawDialogComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); + dialogRefMock = TestBed.inject(DynamicDialogRef) as unknown as { close: jest.Mock }; fixture.detectChanges(); - }); + (store.dispatch as jest.Mock).mockClear(); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should set modal explanation on init', () => { - expect(component.modalExplanation()).toBeDefined(); - expect(typeof component.modalExplanation()).toBe('string'); + it('should set modal explanation for pre-moderation pending', () => { + setup({ + provider: { ...mockProvider, reviewsWorkflow: ProviderReviewsWorkflow.PreModeration }, + preprint: { ...mockPreprint, reviewsState: ReviewsState.Pending }, + }); + expect(component.modalExplanation()).toContain('preprints.details.withdrawDialog.preModerationNoticePending'); }); - it('should handle form validation correctly', () => { - const formControl = component.withdrawalJustificationFormControl; - - formControl.setValue(''); - expect(formControl.invalid).toBe(true); - - const minLength = formInputLimits.withdrawalJustification.minLength; - formControl.setValue('a'.repeat(minLength)); - expect(formControl.valid).toBe(true); + it('should set modal explanation for pre-moderation accepted', () => { + setup({ + provider: { ...mockProvider, reviewsWorkflow: ProviderReviewsWorkflow.PreModeration }, + preprint: { ...mockPreprint, reviewsState: ReviewsState.Accepted }, + }); + expect(component.modalExplanation()).toContain('preprints.details.withdrawDialog.preModerationNoticeAccepted'); }); - it('should handle withdraw with valid form', () => { - const validJustification = 'Valid withdrawal justification'; - component.withdrawalJustificationFormControl.setValue(validJustification); - - expect(() => component.withdraw()).not.toThrow(); + it('should set modal explanation for post-moderation', () => { + setup({ + provider: { ...mockProvider, reviewsWorkflow: ProviderReviewsWorkflow.PostModeration }, + }); + expect(component.modalExplanation()).toContain('preprints.details.withdrawDialog.postModerationNotice'); }); - it('should not proceed with withdraw if form is invalid', () => { - component.withdrawalJustificationFormControl.setValue(''); - - expect(() => component.withdraw()).not.toThrow(); + it('should set modal explanation for no moderation by default', () => { + setup({ + provider: { ...mockProvider, reviewsWorkflow: null }, + }); + expect(component.modalExplanation()).toContain('preprints.details.withdrawDialog.noModerationNotice'); }); - it('should handle withdraw request completion', () => { - const validJustification = 'Valid withdrawal justification'; - component.withdrawalJustificationFormControl.setValue(validJustification); - - expect(() => component.withdraw()).not.toThrow(); + it('should set empty explanation when dialog data is missing', () => { + setup({ + provider: undefined, + preprint: undefined, + }); + expect(component.modalExplanation()).toBe(''); }); - it('should handle withdraw request error', () => { - const validJustification = 'Valid withdrawal justification'; - component.withdrawalJustificationFormControl.setValue(validJustification); - - expect(() => component.withdraw()).not.toThrow(); - }); - - it('should calculate modal explanation for pre-moderation pending', () => { - const providerWithPreMod = { ...mockProvider, reviewsWorkflow: ProviderReviewsWorkflow.PreModeration }; - const preprintWithPending = { ...mockPreprint, reviewsState: ReviewsState.Pending }; - - dialogConfigMock.data = { provider: providerWithPreMod, preprint: preprintWithPending }; - - expect(() => { - fixture = TestBed.createComponent(PreprintWithdrawDialogComponent); - component = fixture.componentInstance; - component.ngOnInit(); - }).not.toThrow(); + it('should keep invalid for empty and whitespace-only justification', () => { + setup(); + component.withdrawalJustificationFormControl.setValue(''); + expect(component.withdrawalJustificationFormControl.hasError('required')).toBe(true); + component.withdrawalJustificationFormControl.setValue(' '); + expect(component.withdrawalJustificationFormControl.hasError('required')).toBe(true); }); - it('should calculate modal explanation for pre-moderation accepted', () => { - const providerWithPreMod = { ...mockProvider, reviewsWorkflow: ProviderReviewsWorkflow.PreModeration }; - const preprintWithAccepted = { ...mockPreprint, reviewsState: ReviewsState.Accepted }; - - dialogConfigMock.data = { provider: providerWithPreMod, preprint: preprintWithAccepted }; - - expect(() => { - fixture = TestBed.createComponent(PreprintWithdrawDialogComponent); - component = fixture.componentInstance; - component.ngOnInit(); - }).not.toThrow(); + it('should enforce minimum length validation', () => { + setup(); + const minLength = formInputLimits.withdrawalJustification.minLength; + component.withdrawalJustificationFormControl.setValue('a'.repeat(minLength - 1)); + expect(component.withdrawalJustificationFormControl.hasError('minlength')).toBe(true); + component.withdrawalJustificationFormControl.setValue('a'.repeat(minLength)); + expect(component.withdrawalJustificationFormControl.hasError('minlength')).toBe(false); }); - it('should calculate modal explanation for post-moderation', () => { - const providerWithPostMod = { ...mockProvider, reviewsWorkflow: ProviderReviewsWorkflow.PostModeration }; - - dialogConfigMock.data = { provider: providerWithPostMod, preprint: mockPreprint }; - - expect(() => { - fixture = TestBed.createComponent(PreprintWithdrawDialogComponent); - component = fixture.componentInstance; - component.ngOnInit(); - }).not.toThrow(); + it('should not dispatch withdraw when form is invalid', () => { + setup(); + component.withdrawalJustificationFormControl.setValue(''); + component.withdraw(); + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should handle form control state changes', () => { - const formControl = component.withdrawalJustificationFormControl; - formControl.markAsTouched(); - expect(formControl.touched).toBe(true); - - formControl.setValue('test'); - formControl.markAsDirty(); - expect(formControl.dirty).toBe(true); + it('should dispatch withdraw and close dialog on success', () => { + setup(); + component.withdrawalJustificationFormControl.setValue('Valid withdrawal justification'); + (store.dispatch as jest.Mock).mockReturnValue(of(true)); + component.withdraw(); + expect(store.dispatch).toHaveBeenCalledWith( + new WithdrawPreprint(mockPreprint.id, 'Valid withdrawal justification') + ); + expect(component.withdrawRequestInProgress()).toBe(false); + expect(dialogRefMock.close).toHaveBeenCalledWith(true); }); - it('should handle minimum length validation', () => { - const formControl = component.withdrawalJustificationFormControl; - const minLength = formInputLimits.withdrawalJustification.minLength; - - formControl.setValue('a'.repeat(minLength - 1)); - expect(formControl.hasError('minlength')).toBe(true); - - formControl.setValue('a'.repeat(minLength)); - expect(formControl.hasError('minlength')).toBe(false); + it('should reset loading and not close dialog on withdraw error', () => { + setup(); + component.withdrawalJustificationFormControl.setValue('Valid withdrawal justification'); + (store.dispatch as jest.Mock).mockReturnValue(throwError(() => new Error('withdraw failed'))); + component.withdraw(); + expect(component.withdrawRequestInProgress()).toBe(false); + expect(dialogRefMock.close).not.toHaveBeenCalled(); }); - it('should handle required validation', () => { - const formControl = component.withdrawalJustificationFormControl; - - formControl.setValue(''); - expect(formControl.hasError('required')).toBe(true); - - formControl.setValue(' '); - expect(formControl.hasError('required')).toBe(true); - - formControl.setValue('Valid text'); - expect(formControl.hasError('required')).toBe(false); + it('should not dispatch withdraw when preprint is missing', () => { + setup({ + provider: mockProvider, + preprint: undefined, + }); + component.withdrawalJustificationFormControl.setValue('Valid withdrawal justification'); + component.withdraw(); + expect(store.dispatch).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.ts b/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.ts index 9b6cfe6c3..f51a091ad 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.ts @@ -7,6 +7,8 @@ import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { Message } from 'primeng/message'; import { Textarea } from 'primeng/textarea'; +import { finalize } from 'rxjs'; + import { TitleCasePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core'; import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -22,7 +24,7 @@ import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.hel @Component({ selector: 'osf-preprint-withdraw-dialog', - imports: [Textarea, ReactiveFormsModule, Message, TranslatePipe, Button, TitleCasePipe], + imports: [Button, Message, Textarea, ReactiveFormsModule, TranslatePipe, TitleCasePipe], templateUrl: './preprint-withdraw-dialog.component.html', styleUrl: './preprint-withdraw-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -39,9 +41,9 @@ export class PreprintWithdrawDialogComponent implements OnInit { private provider!: PreprintProviderDetails; private preprint!: PreprintModel; - private actions = createDispatchMap({ withdrawPreprint: WithdrawPreprint }); + private readonly actions = createDispatchMap({ withdrawPreprint: WithdrawPreprint }); - inputLimits = formInputLimits; + readonly inputLimits = formInputLimits; readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; withdrawalJustificationFormControl = new FormControl('', { @@ -51,34 +53,46 @@ export class PreprintWithdrawDialogComponent implements OnInit { Validators.minLength(this.inputLimits.withdrawalJustification.minLength), ], }); + modalExplanation = signal(''); withdrawRequestInProgress = signal(false); - documentType!: Record; - public ngOnInit() { - this.provider = this.config.data.provider; - this.preprint = this.config.data.preprint; + documentType: Record = { + singular: '', + singularCapitalized: '', + plural: '', + pluralCapitalized: '', + }; + + ngOnInit() { + const data = this.config.data; + + if (!data?.provider || !data.preprint) { + this.modalExplanation.set(''); + return; + } + + this.provider = data.provider; + this.preprint = data.preprint; this.documentType = getPreprintDocumentType(this.provider, this.translateService); this.modalExplanation.set(this.calculateModalExplanation()); } withdraw() { - if (this.withdrawalJustificationFormControl.invalid) { + if (this.withdrawalJustificationFormControl.invalid || !this.preprint) { return; } const withdrawalJustification = this.withdrawalJustificationFormControl.value; this.withdrawRequestInProgress.set(true); - this.actions.withdrawPreprint(this.preprint.id, withdrawalJustification).subscribe({ - complete: () => { - this.withdrawRequestInProgress.set(false); - this.dialogRef.close(true); - }, - error: () => { - this.withdrawRequestInProgress.set(false); - }, - }); + + this.actions + .withdrawPreprint(this.preprint.id, withdrawalJustification) + .pipe(finalize(() => this.withdrawRequestInProgress.set(false))) + .subscribe({ + complete: () => this.dialogRef.close(true), + }); } private calculateModalExplanation() { @@ -90,11 +104,12 @@ export class PreprintWithdrawDialogComponent implements OnInit { return this.translateService.instant('preprints.details.withdrawDialog.preModerationNoticePending', { singularPreprintWord: this.documentType.singular, }); - } else + } else { return this.translateService.instant('preprints.details.withdrawDialog.preModerationNoticeAccepted', { singularPreprintWord: this.documentType.singular, pluralCapitalizedPreprintWord: this.documentType.pluralCapitalized, }); + } } case ProviderReviewsWorkflow.PostModeration: { return this.translateService.instant('preprints.details.withdrawDialog.postModerationNotice', { diff --git a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.html b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.html index 303fcec3a..97519bcf5 100644 --- a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.html +++ b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.html @@ -1,23 +1,26 @@ +@let preprintValue = preprint(); +@let providerValue = preprintProvider(); +
- @if (preprint() && preprintProvider()) { + @if (preprintValue && providerValue) { }
diff --git a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts index 544da7073..753ee402d 100644 --- a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts @@ -1,68 +1,125 @@ import { MockComponent, MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; - +import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { PreprintProviderDetails } from '@osf/features/preprints/models'; import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { SocialsShareButtonComponent } from '@osf/shared/components/socials-share-button/socials-share-button.component'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; +import { SocialShareService } from '@osf/shared/services/social-share.service'; import { ShareAndDownloadComponent } from './share-and-download.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 { provideMockStore } from '@testing/providers/store-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { DataciteServiceMockBuilder, DataciteServiceMockType } from '@testing/providers/datacite.service.mock'; +import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; describe('ShareAndDownloadComponent', () => { let component: ShareAndDownloadComponent; let fixture: ComponentFixture; - let dataciteService: jest.Mocked; + let dataciteService: DataciteServiceMockType; + let socialShareService: { createDownloadUrl: jest.Mock }; const mockPreprint = PREPRINT_MOCK; - const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; + const mockProvider = PREPRINT_PROVIDER_DETAILS_MOCK; + + interface SetupOverrides extends BaseSetupOverrides { + platformId?: string; + } - beforeEach(async () => { - dataciteService = { - logIdentifiableDownload: jest.fn().mockReturnValue(of(void 0)), - } as any; + function setup(overrides: SetupOverrides = {}) { + dataciteService = DataciteServiceMockBuilder.create().build(); + socialShareService = { createDownloadUrl: jest.fn().mockReturnValue('https://example.com/download') }; - await TestBed.configureTestingModule({ - imports: [ShareAndDownloadComponent, OSFTestingModule, MockComponent(SocialsShareButtonComponent)], + TestBed.configureTestingModule({ + imports: [ShareAndDownloadComponent, MockComponent(SocialsShareButtonComponent)], providers: [ - TranslationServiceMock, + provideOSFCore(), + MockProvider(PLATFORM_ID, overrides.platformId ?? 'browser'), MockProvider(DataciteService, dataciteService), + MockProvider(SocialShareService, socialShareService), provideMockStore({ - signals: [ - { - selector: PreprintSelectors.getPreprint, - value: mockPreprint, - }, - ], + signals: mergeSignalOverrides( + [{ selector: PreprintSelectors.getPreprint, value: mockPreprint }], + overrides.selectorOverrides + ), }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(ShareAndDownloadComponent); component = fixture.componentInstance; fixture.componentRef.setInput('preprintProvider', mockProvider); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); it('should return preprint from store', () => { + setup(); const preprint = component.preprint(); expect(preprint).toBe(mockPreprint); }); it('should handle preprint provider input', () => { + setup(); const provider = component.preprintProvider(); expect(provider).toBe(mockProvider); }); + + it('should open download link and log identifiable download', () => { + setup(); + const focus = jest.fn(); + const openSpy = jest.spyOn(window, 'open').mockReturnValue({ focus } as unknown as Window); + + component.download(); + + expect(socialShareService.createDownloadUrl).toHaveBeenCalledWith(mockPreprint.id); + expect(openSpy).toHaveBeenCalledWith('https://example.com/download'); + expect(focus).toHaveBeenCalled(); + expect(dataciteService.logIdentifiableDownload).toHaveBeenCalledWith(component.preprint$); + openSpy.mockRestore(); + }); + + it('should not do anything when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: null }], + }); + const openSpy = jest.spyOn(window, 'open'); + + component.download(); + + expect(socialShareService.createDownloadUrl).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + expect(dataciteService.logIdentifiableDownload).not.toHaveBeenCalled(); + openSpy.mockRestore(); + }); + + it('should not open or log when not running in browser', () => { + setup({ platformId: 'server' }); + const openSpy = jest.spyOn(window, 'open'); + + component.download(); + + expect(socialShareService.createDownloadUrl).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + expect(dataciteService.logIdentifiableDownload).not.toHaveBeenCalled(); + openSpy.mockRestore(); + }); + + it('should not log when window.open fails', () => { + setup(); + const openSpy = jest.spyOn(window, 'open').mockReturnValue(null); + + component.download(); + + expect(socialShareService.createDownloadUrl).toHaveBeenCalledWith(mockPreprint.id); + expect(dataciteService.logIdentifiableDownload).not.toHaveBeenCalled(); + openSpy.mockRestore(); + }); }); diff --git a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.ts b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.ts index b0fae3b09..896c48f40 100644 --- a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.ts +++ b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.ts @@ -5,7 +5,8 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Tooltip } from 'primeng/tooltip'; -import { ChangeDetectionStrategy, Component, DestroyRef, inject, input } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, PLATFORM_ID } from '@angular/core'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { PreprintProviderDetails } from '@osf/features/preprints/models'; @@ -28,20 +29,28 @@ export class ShareAndDownloadComponent { private readonly socialShareService = inject(SocialShareService); private readonly destroyRef = inject(DestroyRef); private readonly dataciteService = inject(DataciteService); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); - preprint = select(PreprintSelectors.getPreprint); - preprint$ = toObservable(this.preprint); - resourceType = ResourceType.Preprint; + readonly preprint = select(PreprintSelectors.getPreprint); + readonly preprint$ = toObservable(this.preprint); + + readonly resourceType = ResourceType.Preprint; download() { const preprint = this.preprint(); - if (!preprint) { + if (!preprint || !this.isBrowser) { return; } const downloadLink = this.socialShareService.createDownloadUrl(preprint.id); - window.open(downloadLink)?.focus(); + const downloadWindow = window.open(downloadLink); + + if (!downloadWindow) { + return; + } + + downloadWindow.focus(); this.dataciteService.logIdentifiableDownload(this.preprint$).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); } diff --git a/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.html b/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.html index c4c9c9424..f17c38f9d 100644 --- a/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.html +++ b/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.html @@ -10,10 +10,10 @@
@if (reviewerComment() && !provider().reviewsCommentsPrivate && !isPendingWithdrawal()) { { let component: StatusBannerComponent; @@ -23,70 +24,147 @@ describe('StatusBannerComponent', () => { const mockPreprint: PreprintModel = PREPRINT_MOCK; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; const mockReviewAction: ReviewAction = REVIEW_ACTION_MOCK; - const mockRequestAction = REVIEW_ACTION_MOCK; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [StatusBannerComponent, OSFTestingModule, MockComponent(IconComponent), MockPipe(TitleCasePipe)], + const mockRequestAction: PreprintRequestAction = REVIEW_ACTION_MOCK as unknown as PreprintRequestAction; + + interface SetupOverrides extends BaseSetupOverrides { + provider?: PreprintProviderDetails; + latestAction?: ReviewAction | null; + latestRequestAction?: PreprintRequestAction | null; + isPendingWithdrawal?: boolean; + isWithdrawalRejected?: boolean; + } + + function setup(overrides: SetupOverrides = {}) { + TestBed.configureTestingModule({ + imports: [StatusBannerComponent, MockComponent(IconComponent), MockPipe(TitleCasePipe)], providers: [ + provideOSFCore(), provideMockStore({ - signals: [ - { - selector: PreprintSelectors.getPreprint, - value: mockPreprint, - }, - ], + signals: mergeSignalOverrides( + [{ selector: PreprintSelectors.getPreprint, value: mockPreprint }], + overrides.selectorOverrides + ), }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(StatusBannerComponent); component = fixture.componentInstance; - - fixture.componentRef.setInput('provider', mockProvider); - fixture.componentRef.setInput('latestAction', mockReviewAction); - fixture.componentRef.setInput('isPendingWithdrawal', false); - fixture.componentRef.setInput('isWithdrawalRejected', false); - fixture.componentRef.setInput('latestRequestAction', mockRequestAction); - }); + fixture.componentRef.setInput('provider', overrides.provider ?? mockProvider); + fixture.componentRef.setInput( + 'latestAction', + 'latestAction' in overrides ? overrides.latestAction : mockReviewAction + ); + fixture.componentRef.setInput( + 'latestRequestAction', + 'latestRequestAction' in overrides ? overrides.latestRequestAction : mockRequestAction + ); + fixture.componentRef.setInput('isPendingWithdrawal', overrides.isPendingWithdrawal ?? false); + fixture.componentRef.setInput('isWithdrawalRejected', overrides.isWithdrawalRejected ?? false); + fixture.detectChanges(); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should compute severity for pending preprint', () => { - const severity = component.severity(); - expect(severity).toBe('warn'); + it('should compute pending state severity, status and icon', () => { + setup(); + expect(component.severity()).toBe('warn'); + expect(component.status()).toBe('preprints.details.statusBanner.pending'); + expect(component.iconClass()).toBe('hourglass'); + }); + + it('should compute pending withdrawal state severity, status and icon', () => { + setup({ isPendingWithdrawal: true }); + expect(component.severity()).toBe('error'); + expect(component.status()).toBe('preprints.details.statusBanner.pendingWithdrawal'); + expect(component.iconClass()).toBe('hourglass'); + }); + + it('should compute withdrawal rejected state severity, status and icon', () => { + setup({ isWithdrawalRejected: true }); + expect(component.severity()).toBe('error'); + expect(component.status()).toBe('preprints.details.statusBanner.withdrawalRejected'); + expect(component.iconClass()).toBe('times-circle'); + }); + + it('should treat preprint as withdrawn only when dateWithdrawn is truthy', () => { + setup({ + selectorOverrides: [ + { selector: PreprintSelectors.getPreprint, value: { ...mockPreprint, dateWithdrawn: undefined } }, + ], + }); + expect(component.isWithdrawn()).toBe(false); + }); + + it('should compute withdrawn severity/status/icon when dateWithdrawn is set', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { + ...mockPreprint, + reviewsState: ReviewsState.Withdrawn, + dateWithdrawn: '2024-01-01T00:00:00Z', + }, + }, + ], + }); + expect(component.isWithdrawn()).toBe(true); + expect(component.severity()).toBe('warn'); + expect(component.status()).toBe('preprints.details.statusBanner.withdrawn'); + expect(component.iconClass()).toBe('circle-minus'); + }); + + it('should use latest request action reviewer fields when withdrawal is rejected', () => { + setup({ + isWithdrawalRejected: true, + latestRequestAction: { + ...mockRequestAction, + creator: { id: 'user-2', name: 'Request Reviewer' }, + comment: 'Request action comment', + }, + }); + expect(component.reviewerName()).toBe('Request Reviewer'); + expect(component.reviewerComment()).toBe('Request action comment'); }); - it('should compute severity for pending withdrawal', () => { - fixture.componentRef.setInput('isPendingWithdrawal', true); - const severity = component.severity(); - expect(severity).toBe('error'); + it('should use latest action reviewer fields by default', () => { + setup(); + expect(component.reviewerName()).toBe('Test User'); + expect(component.reviewerComment()).toBe('Initial comment'); }); - it('should compute status for pending preprint', () => { - const status = component.status(); - expect(status).toBe('preprints.details.statusBanner.pending'); + it('should return empty reviewer fields when latest action is missing', () => { + setup({ latestAction: null }); + expect(component.reviewerName()).toBe(''); + expect(component.reviewerComment()).toBe(''); }); - it('should compute status for pending withdrawal', () => { - fixture.componentRef.setInput('isPendingWithdrawal', true); - const status = component.status(); - expect(status).toBe('preprints.details.statusBanner.pendingWithdrawal'); + it('should return empty reviewer fields when withdrawal is rejected and request action is missing', () => { + setup({ isWithdrawalRejected: true, latestRequestAction: null }); + expect(component.reviewerName()).toBe(''); + expect(component.reviewerComment()).toBe(''); }); - it('should compute reviewer name from latest action', () => { - const name = component.reviewerName(); - expect(name).toBe('Test User'); + it('should build pending banner content with base and workflow message', () => { + setup(); + const content = component.bannerContent(); + expect(content).toContain('preprints.details.statusBanner.messages.base'); + expect(content).toContain('preprints.details.statusBanner.messages.pendingPreModeration'); }); - it('should compute reviewer comment from latest action', () => { - const comment = component.reviewerComment(); - expect(comment).toBe('Initial comment'); + it('should build withdrawal-related banner content without base message', () => { + setup({ isPendingWithdrawal: true }); + const content = component.bannerContent(); + expect(content).toContain('preprints.details.statusBanner.messages.pendingWithdrawal'); + expect(content).not.toContain('preprints.details.statusBanner.messages.base'); }); - it('should show feedback dialog', () => { + it('should toggle feedback dialog visibility', () => { + setup(); expect(component.feedbackDialogVisible).toBe(false); component.showFeedbackDialog(); expect(component.feedbackDialogVisible).toBe(true); diff --git a/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.ts b/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.ts index 1d9856b88..a8a5e4f8b 100644 --- a/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.ts +++ b/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.ts @@ -26,89 +26,71 @@ import { IconComponent } from '@osf/shared/components/icon/icon.component'; @Component({ selector: 'osf-preprint-status-banner', - imports: [TranslatePipe, TitleCasePipe, Message, Dialog, Tag, Button, IconComponent], + imports: [Button, Dialog, Message, Tag, IconComponent, TitleCasePipe, TranslatePipe], templateUrl: './status-banner.component.html', styleUrl: './status-banner.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class StatusBannerComponent { private readonly translateService = inject(TranslateService); - provider = input.required(); - preprint = select(PreprintSelectors.getPreprint); - latestAction = input.required(); - isPendingWithdrawal = input.required(); - isWithdrawalRejected = input.required(); - latestRequestAction = input.required(); + readonly provider = input.required(); + + readonly latestAction = input.required(); + readonly isPendingWithdrawal = input.required(); + readonly isWithdrawalRejected = input.required(); + readonly latestRequestAction = input.required(); + + readonly preprint = select(PreprintSelectors.getPreprint); feedbackDialogVisible = false; - severity = computed(() => { + currentState = computed(() => { if (this.isPendingWithdrawal()) { - return statusSeverityByState[ReviewsState.PendingWithdrawal]!; - } else if (this.isWithdrawn()) { - return statusSeverityByState[ReviewsState.Withdrawn]!; - } else if (this.isWithdrawalRejected()) { - return statusSeverityByState[ReviewsState.WithdrawalRejected]!; - } else { - const reviewsState = this.preprint()?.reviewsState; - - return reviewsState === ReviewsState.Pending - ? statusSeverityByWorkflow[this.provider()?.reviewsWorkflow as ProviderReviewsWorkflow] - : statusSeverityByState[this.preprint()!.reviewsState]!; + return ReviewsState.PendingWithdrawal; } - }); - - status = computed(() => { - let currentState = this.preprint()!.reviewsState; - if (this.isPendingWithdrawal()) { - currentState = ReviewsState.PendingWithdrawal; - } else if (this.isWithdrawalRejected()) { - currentState = ReviewsState.WithdrawalRejected; + if (this.isWithdrawalRejected()) { + return ReviewsState.WithdrawalRejected; } - return statusLabelKeyByState[currentState]!; + return this.preprint()?.reviewsState ?? ReviewsState.Pending; }); - iconClass = computed(() => { - let currentState = this.preprint()!.reviewsState; + severity = computed(() => { + const currentState = this.currentState(); + const workflow = this.provider()?.reviewsWorkflow; + + if (this.isWithdrawn()) { + return statusSeverityByState[ReviewsState.Withdrawn]; + } - if (this.isPendingWithdrawal()) { - currentState = ReviewsState.PendingWithdrawal; - } else if (this.isWithdrawalRejected()) { - currentState = ReviewsState.WithdrawalRejected; + if (currentState === ReviewsState.Pending && workflow) { + return statusSeverityByWorkflow[workflow]; } - return statusIconByState[currentState]; + return statusSeverityByState[currentState]; }); + status = computed(() => statusLabelKeyByState[this.currentState()]!); + iconClass = computed(() => statusIconByState[this.currentState()]); + reviewerName = computed(() => { - if (this.isWithdrawalRejected()) { - return this.latestRequestAction()?.creator.name; - } else { - return this.latestAction()?.creator?.name; - } + const action = this.isWithdrawalRejected() ? this.latestRequestAction() : this.latestAction(); + return action?.creator?.name ?? ''; }); reviewerComment = computed(() => { - if (this.isWithdrawalRejected()) { - return this.latestRequestAction()?.comment; - } else { - return this.latestAction()?.comment; - } + const action = this.isWithdrawalRejected() ? this.latestRequestAction() : this.latestAction(); + return action?.comment ?? ''; }); - isWithdrawn = computed(() => { - return this.preprint()?.dateWithdrawn !== null; - }); + isWithdrawn = computed(() => Boolean(this.preprint()?.dateWithdrawn)); bannerContent = computed(() => { const documentType = this.provider().preprintWord; if (this.isPendingWithdrawal() || this.isWithdrawn() || this.isWithdrawalRejected()) { - return this.translateService.instant(this.statusExplanation(), { - documentType, - }); + return this.translateService.instant(this.statusExplanation(), { documentType }); } else { const name = this.provider()!.name; const workflow = this.provider()?.reviewsWorkflow; @@ -124,16 +106,10 @@ export class StatusBannerComponent { }); private statusExplanation = computed(() => { - if (this.isPendingWithdrawal()) { - return statusMessageByState[ReviewsState.PendingWithdrawal]!; - } else if (this.isWithdrawalRejected()) { - return statusMessageByState[ReviewsState.WithdrawalRejected]!; - } else { - const reviewsState = this.preprint()?.reviewsState; - return reviewsState === ReviewsState.Pending - ? statusMessageByWorkflow[this.provider()?.reviewsWorkflow as ProviderReviewsWorkflow] - : statusMessageByState[this.preprint()!.reviewsState]!; - } + const currentState = this.currentState(); + return currentState === ReviewsState.Pending + ? statusMessageByWorkflow[this.provider()?.reviewsWorkflow as ProviderReviewsWorkflow] + : statusMessageByState[currentState]!; }); showFeedbackDialog() { diff --git a/src/app/features/preprints/mappers/preprints.mapper.ts b/src/app/features/preprints/mappers/preprints.mapper.ts index 7d2e8defa..ed01f2be9 100644 --- a/src/app/features/preprints/mappers/preprints.mapper.ts +++ b/src/app/features/preprints/mappers/preprints.mapper.ts @@ -71,12 +71,14 @@ export class PreprintsMapper { } : null, hasCoi: response.attributes.has_coi, - coiStatement: response.attributes.conflict_of_interest_statement, + coiStatement: response.attributes.conflict_of_interest_statement + ? replaceBadEncodedChars(response.attributes.conflict_of_interest_statement) + : null, hasDataLinks: response.attributes.has_data_links, dataLinks: response.attributes.data_links, - whyNoData: response.attributes.why_no_data, + whyNoData: response.attributes.why_no_data ? replaceBadEncodedChars(response.attributes.why_no_data) : null, hasPreregLinks: response.attributes.has_prereg_links, - whyNoPrereg: response.attributes.why_no_prereg, + whyNoPrereg: response.attributes.why_no_prereg ? replaceBadEncodedChars(response.attributes.why_no_prereg) : null, preregLinks: response.attributes.prereg_links, preregLinkInfo: response.attributes.prereg_link_info, preprintDoiLink: response.links.preprint_doi, @@ -106,7 +108,7 @@ export class PreprintsMapper { datePublished: data.attributes.date_published, dateLastTransitioned: data.attributes.date_last_transitioned, title: replaceBadEncodedChars(data.attributes.title), - description: data.attributes.description, + description: replaceBadEncodedChars(data.attributes.description), reviewsState: data.attributes.reviews_state, preprintDoiCreated: data.attributes.preprint_doi_created, currentUserPermissions: data.attributes.current_user_permissions, @@ -130,12 +132,14 @@ export class PreprintsMapper { } : null, hasCoi: data.attributes.has_coi, - coiStatement: data.attributes.conflict_of_interest_statement, + coiStatement: data.attributes.conflict_of_interest_statement + ? replaceBadEncodedChars(data.attributes.conflict_of_interest_statement) + : null, hasDataLinks: data.attributes.has_data_links, dataLinks: data.attributes.data_links, - whyNoData: data.attributes.why_no_data, + whyNoData: data.attributes.why_no_data ? replaceBadEncodedChars(data.attributes.why_no_data) : null, hasPreregLinks: data.attributes.has_prereg_links, - whyNoPrereg: data.attributes.why_no_prereg, + whyNoPrereg: data.attributes.why_no_prereg ? replaceBadEncodedChars(data.attributes.why_no_prereg) : null, preregLinks: data.attributes.prereg_links, preregLinkInfo: data.attributes.prereg_link_info, embeddedLicense: LicensesMapper.fromLicenseDataJsonApi(data.embeds?.license?.data), 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 eba4c1d04..ba30e4bd3 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 @@ -53,7 +53,7 @@

{{ preprint()?.title }}

class="w-full md:w-11rem" styleClass="w-full" [label]="editButtonLabel() | translate" - (click)="editPreprintClicked()" + (onClick)="editPreprintClicked()" /> } @if (createNewVersionButtonVisible()) { @@ -61,7 +61,7 @@

{{ preprint()?.title }}

class="w-full md:w-11rem" styleClass="w-full" [label]="'common.buttons.createNewVersion' | translate" - (click)="createNewVersionClicked()" + (onClick)="createNewVersionClicked()" /> } @if (withdrawalButtonVisible()) { @@ -70,7 +70,7 @@

{{ preprint()?.title }}

styleClass="w-full" [label]="'common.buttons.withdraw' | translate" severity="danger" - (click)="handleWithdrawClicked()" + (onClick)="handleWithdrawClicked()" /> } diff --git a/src/testing/providers/datacite.service.mock.ts b/src/testing/providers/datacite.service.mock.ts new file mode 100644 index 000000000..37a207325 --- /dev/null +++ b/src/testing/providers/datacite.service.mock.ts @@ -0,0 +1,59 @@ +import { of } from 'rxjs'; + +import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; + +export type DataciteServiceMockType = Partial & { + logIdentifiableView: jest.Mock; + logIdentifiableDownload: jest.Mock; + logFileDownload: jest.Mock; + logFileView: jest.Mock; +}; + +export class DataciteServiceMockBuilder { + private logIdentifiableViewMock: jest.Mock = jest.fn().mockReturnValue(of(void 0)); + private logIdentifiableDownloadMock: jest.Mock = jest.fn().mockReturnValue(of(void 0)); + private logFileDownloadMock: jest.Mock = jest.fn().mockReturnValue(of(void 0)); + private logFileViewMock: jest.Mock = jest.fn().mockReturnValue(of(void 0)); + + static create(): DataciteServiceMockBuilder { + return new DataciteServiceMockBuilder(); + } + + withLogIdentifiableView(mockImpl: jest.Mock): DataciteServiceMockBuilder { + this.logIdentifiableViewMock = mockImpl; + return this; + } + + withLogIdentifiableDownload(mockImpl: jest.Mock): DataciteServiceMockBuilder { + this.logIdentifiableDownloadMock = mockImpl; + return this; + } + + withLogFileDownload(mockImpl: jest.Mock): DataciteServiceMockBuilder { + this.logFileDownloadMock = mockImpl; + return this; + } + + withLogFileView(mockImpl: jest.Mock): DataciteServiceMockBuilder { + this.logFileViewMock = mockImpl; + return this; + } + + build(): DataciteServiceMockType { + return { + logIdentifiableView: this.logIdentifiableViewMock, + logIdentifiableDownload: this.logIdentifiableDownloadMock, + logFileDownload: this.logFileDownloadMock, + logFileView: this.logFileViewMock, + } as DataciteServiceMockType; + } +} + +export const DataciteServiceMock = { + create() { + return DataciteServiceMockBuilder.create(); + }, + simple() { + return DataciteServiceMockBuilder.create().build(); + }, +}; From 71f3a65c456c79dfcfbd688770401b8a19e469e1 Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 5 Mar 2026 16:09:12 +0200 Subject: [PATCH 21/27] fix(commands): updated commands for angular serve --- angular.json | 17 +++++++++++++++++ package.json | 1 + 2 files changed, 18 insertions(+) diff --git a/angular.json b/angular.json index 45489de17..1457228db 100644 --- a/angular.json +++ b/angular.json @@ -117,6 +117,20 @@ "namedChunks": true }, "development": { + "outputMode": "static", + "server": false, + "ssr": false, + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ] + }, + "dev-ssr": { "optimization": false, "extractLicenses": false, "sourceMap": true, @@ -164,6 +178,9 @@ "development": { "buildTarget": "osf:build:development" }, + "dev-ssr": { + "buildTarget": "osf:build:dev-ssr" + }, "docker": { "buildTarget": "osf:build:docker" }, diff --git a/package.json b/package.json index 80657c032..fafb56652 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "ngxs:store": "ng generate @ngxs/store:store --name --path", "prepare": "husky", "start": "ng serve", + "start:ssr": "ng serve --configuration dev-ssr", "start:docker": "npm run check:config && ng serve --host 0.0.0.0 --port 4200 --poll 2000 --configuration development", "start:docker:local": "npm run check:config && ng serve --host 0.0.0.0 --port 4200 --poll 2000 --configuration docker", "test": "jest", From ee5d1bb7e14da92d8ea2a4dc3f7c78ab48fbafc5 Mon Sep 17 00:00:00 2001 From: Vlad0n20 <137097005+Vlad0n20@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:28:57 +0200 Subject: [PATCH 22/27] fix(ENG-10087): Update preprint download url (#901) - Ticket: [ENG-10087] - Feature flag: n/a ## Purpose Fix preprint download link ## Summary of Changes update preprint download link --- src/app/app.routes.ts | 7 ++ ...eprint-download-redirect.component.spec.ts | 82 +++++++++++++++++++ .../preprint-download-redirect.component.ts | 30 +++++++ src/assets/i18n/en.json | 3 + 4 files changed, 122 insertions(+) create mode 100644 src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.spec.ts create mode 100644 src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 49c222492..1de586b65 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -107,6 +107,13 @@ export const routes: Routes = [ '@osf/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component' ).then((mod) => mod.PreprintPendingModerationComponent), }, + { + path: 'preprints/:providerId/:id/download', + loadComponent: () => + import('@osf/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component').then( + (c) => c.PreprintDownloadRedirectComponent + ), + }, { path: 'preprints/:providerId/:id', loadComponent: () => diff --git a/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.spec.ts b/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.spec.ts new file mode 100644 index 000000000..1aabf7386 --- /dev/null +++ b/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.spec.ts @@ -0,0 +1,82 @@ +import { MockProvider } from 'ng-mocks'; + +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { SocialShareService } from '@osf/shared/services/social-share.service'; + +import { PreprintDownloadRedirectComponent } from './preprint-download-redirect.component'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; + +const MOCK_ID = 'test-preprint-id'; +const MOCK_DOWNLOAD_URL = 'https://osf.io/download/test-preprint-id'; + +describe('PreprintDownloadRedirectComponent', () => { + let locationReplaceMock: jest.Mock; + + beforeEach(() => { + locationReplaceMock = jest.fn(); + Object.defineProperty(window, 'location', { + value: { replace: locationReplaceMock }, + writable: true, + configurable: true, + }); + }); + + function setup(overrides: { id?: string | null; isBrowser?: boolean } = {}) { + const { id = MOCK_ID, isBrowser = true } = overrides; + + const mockRoute = ActivatedRouteMockBuilder.create() + .withParams(id ? { id } : {}) + .build(); + + const mockSocialShareService = { + createDownloadUrl: jest.fn().mockReturnValue(MOCK_DOWNLOAD_URL), + }; + + TestBed.configureTestingModule({ + imports: [PreprintDownloadRedirectComponent], + providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(SocialShareService, mockSocialShareService), + MockProvider(PLATFORM_ID, isBrowser ? 'browser' : 'server'), + ], + }); + + const fixture = TestBed.createComponent(PreprintDownloadRedirectComponent); + return { fixture, component: fixture.componentInstance, mockSocialShareService }; + } + + it('should create', () => { + const { component } = setup(); + expect(component).toBeTruthy(); + }); + + it('should render download message', () => { + const { fixture } = setup(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('p').textContent).toContain('preprints.downloadRedirect.message'); + }); + + it('should redirect to download URL when id is present in browser', () => { + const { mockSocialShareService } = setup({ id: MOCK_ID }); + expect(mockSocialShareService.createDownloadUrl).toHaveBeenCalledWith(MOCK_ID); + expect(locationReplaceMock).toHaveBeenCalledWith(MOCK_DOWNLOAD_URL); + }); + + it('should not redirect when id is missing', () => { + const { mockSocialShareService } = setup({ id: null }); + expect(mockSocialShareService.createDownloadUrl).not.toHaveBeenCalled(); + expect(locationReplaceMock).not.toHaveBeenCalled(); + }); + + it('should not redirect when not in browser', () => { + const { mockSocialShareService } = setup({ isBrowser: false }); + expect(mockSocialShareService.createDownloadUrl).not.toHaveBeenCalled(); + expect(locationReplaceMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.ts b/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.ts new file mode 100644 index 000000000..aa8bfc451 --- /dev/null +++ b/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.ts @@ -0,0 +1,30 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { isPlatformBrowser } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, PLATFORM_ID } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { SocialShareService } from '@osf/shared/services/social-share.service'; + +@Component({ + selector: 'osf-preprint-download-redirect', + template: `

{{ 'preprints.downloadRedirect.message' | translate }}

`, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TranslatePipe], +}) +export class PreprintDownloadRedirectComponent { + private readonly route = inject(ActivatedRoute); + private readonly socialShareService = inject(SocialShareService); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + constructor() { + const id = this.route.snapshot.paramMap.get('id') ?? ''; + + if (!id || !this.isBrowser) { + return; + } + + const url = this.socialShareService.createDownloadUrl(id); + window.location.replace(url); + } +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 6e2433aa5..768a18305 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2345,6 +2345,9 @@ "singularCapitalized": "Thesis" } }, + "downloadRedirect": { + "message": "Your download will begin shortly." + }, "details": { "reasonForWithdrawal": "Reason for withdrawal", "originalPublicationDate": "Original Publication Date", From 4a85dd351353b83a818eda9b25e4abaafbdafe0c Mon Sep 17 00:00:00 2001 From: nsemets Date: Fri, 6 Mar 2026 16:25:13 +0200 Subject: [PATCH 23/27] [ENG-10364] Part 4: Add unit tests for preprints (#902) - Ticket: [ENG-10364] - Feature flag: n/a ## Summary of Changes 1. Updated unit tests for preprints components. 2. Updated coverage thresholds. --- jest.config.js | 8 +- .../advisory-board.component.spec.ts | 68 ++----- .../advisory-board.component.ts | 2 - .../browse-by-subjects.component.html | 6 +- .../browse-by-subjects.component.spec.ts | 155 +++++--------- .../browse-by-subjects.component.ts | 28 +-- ...preprint-provider-footer.component.spec.ts | 45 +---- .../preprint-provider-footer.component.ts | 2 +- .../preprint-provider-hero.component.html | 34 ++-- .../preprint-provider-hero.component.scss | 12 -- .../preprint-provider-hero.component.spec.ts | 189 ++++++------------ .../preprint-provider-hero.component.ts | 18 +- .../preprint-services.component.spec.ts | 28 ++- .../preprints-help-dialog.component.html | 4 +- .../preprints-help-dialog.component.spec.ts | 11 +- src/app/features/preprints/guards/index.ts | 1 - .../guards/preprints-moderator.guard.spec.ts | 53 +++++ .../guards/preprints-moderator.guard.ts | 4 +- .../preprint-details.component.spec.ts | 8 + .../services/preprint-files.service.ts | 23 +-- 20 files changed, 292 insertions(+), 407 deletions(-) delete mode 100644 src/app/features/preprints/guards/index.ts create mode 100644 src/app/features/preprints/guards/preprints-moderator.guard.spec.ts diff --git a/jest.config.js b/jest.config.js index ad155721d..4d2d69fcc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -54,10 +54,10 @@ module.exports = { extensionsToTreatAsEsm: ['.ts'], coverageThreshold: { global: { - branches: 39.5, - functions: 41.1, - lines: 68.0, - statements: 68.4, + branches: 43.3, + functions: 42.7, + lines: 69.3, + statements: 69.8, }, }, watchPathIgnorePatterns: [ diff --git a/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts b/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts index 588caf723..8119d7b99 100644 --- a/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts +++ b/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts @@ -9,84 +9,54 @@ describe('AdvisoryBoardComponent', () => { const mockHtmlContent = '

Advisory Board

This is advisory board content.

'; - beforeEach(async () => { - await TestBed.configureTestingModule({ + beforeEach(() => { + TestBed.configureTestingModule({ imports: [AdvisoryBoardComponent], - }).compileComponents(); + }); fixture = TestBed.createComponent(AdvisoryBoardComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); + function getSection(): HTMLElement | null { + return fixture.nativeElement.querySelector('section'); + } it('should have default input values', () => { expect(component.htmlContent()).toBeNull(); - expect(component.brand()).toBeUndefined(); expect(component.isLandingPage()).toBe(false); }); - it('should not render section when htmlContent is null', () => { + it.each([null, undefined])('should not render section when htmlContent is %s', (htmlContent) => { + fixture.componentRef.setInput('htmlContent', htmlContent); fixture.detectChanges(); - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); - - expect(section).toBeNull(); - }); - - it('should not render section when htmlContent is undefined', () => { - fixture.componentRef.setInput('htmlContent', undefined); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); - - expect(section).toBeNull(); + expect(getSection()).toBeNull(); }); it('should render section when htmlContent is provided', () => { fixture.componentRef.setInput('htmlContent', mockHtmlContent); fixture.detectChanges(); - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); - - expect(section).toBeTruthy(); - expect(section.innerHTML).toBe(mockHtmlContent); - }); - - it('should apply correct CSS classes when isLandingPage is false', () => { - fixture.componentRef.setInput('htmlContent', mockHtmlContent); - fixture.componentRef.setInput('isLandingPage', false); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); + const section = getSection(); expect(section).toBeTruthy(); - expect(section.classList.contains('osf-preprint-service')).toBe(false); - expect(section.classList.contains('preprints-advisory-board-section')).toBe(true); - expect(section.classList.contains('pt-3')).toBe(true); - expect(section.classList.contains('pb-5')).toBe(true); - expect(section.classList.contains('px-3')).toBe(true); - expect(section.classList.contains('flex')).toBe(true); - expect(section.classList.contains('flex-column')).toBe(true); + expect(section?.innerHTML).toContain('Advisory Board'); + expect(section?.innerHTML).toContain('This is advisory board content.'); }); - it('should apply correct CSS classes when isLandingPage is true', () => { + it.each([ + { isLandingPage: false, hasLandingClass: false }, + { isLandingPage: true, hasLandingClass: true }, + ])('should handle landing class when isLandingPage is $isLandingPage', ({ isLandingPage, hasLandingClass }) => { fixture.componentRef.setInput('htmlContent', mockHtmlContent); - fixture.componentRef.setInput('isLandingPage', true); + fixture.componentRef.setInput('isLandingPage', isLandingPage); fixture.detectChanges(); - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); + const section = getSection(); expect(section).toBeTruthy(); - expect(section.classList.contains('osf-preprint-service')).toBe(true); - expect(section.classList.contains('preprints-advisory-board-section')).toBe(true); + expect(section?.classList.contains('osf-preprint-service')).toBe(hasLandingClass); }); }); diff --git a/src/app/features/preprints/components/advisory-board/advisory-board.component.ts b/src/app/features/preprints/components/advisory-board/advisory-board.component.ts index 2840658ce..48435e681 100644 --- a/src/app/features/preprints/components/advisory-board/advisory-board.component.ts +++ b/src/app/features/preprints/components/advisory-board/advisory-board.component.ts @@ -2,7 +2,6 @@ import { NgClass } from '@angular/common'; import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { StringOrNullOrUndefined } from '@osf/shared/helpers/types.helper'; -import { BrandModel } from '@osf/shared/models/brand/brand.model'; import { SafeHtmlPipe } from '@osf/shared/pipes/safe-html.pipe'; @Component({ @@ -14,6 +13,5 @@ import { SafeHtmlPipe } from '@osf/shared/pipes/safe-html.pipe'; }) export class AdvisoryBoardComponent { htmlContent = input(null); - brand = input(); isLandingPage = input(false); } diff --git a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.html b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.html index 6ab5364ec..61646f32b 100644 --- a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.html +++ b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.html @@ -6,14 +6,14 @@

{{ 'preprints.browseBySubjects.title' | translate }}

} } @else { - @for (subject of subjects(); track subject) { + @for (subject of subjects(); track subject.id) { } } diff --git a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts index 8c71f8c0c..b09b902fa 100644 --- a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts +++ b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts @@ -1,4 +1,7 @@ +import { MockProvider } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; import { ResourceType } from '@shared/enums/resource-type.enum'; import { SubjectModel } from '@shared/models/subject/subject.model'; @@ -6,7 +9,8 @@ import { SubjectModel } from '@shared/models/subject/subject.model'; import { BrowseBySubjectsComponent } from './browse-by-subjects.component'; import { SUBJECTS_MOCK } from '@testing/mocks/subject.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; describe('BrowseBySubjectsComponent', () => { let component: BrowseBySubjectsComponent; @@ -14,130 +18,77 @@ describe('BrowseBySubjectsComponent', () => { const mockSubjects: SubjectModel[] = SUBJECTS_MOCK; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BrowseBySubjectsComponent, OSFTestingModule], - }).compileComponents(); + function setup(overrides?: { + subjects?: SubjectModel[]; + areSubjectsLoading?: boolean; + isProviderLoading?: boolean; + isLandingPage?: boolean; + }) { + TestBed.configureTestingModule({ + imports: [BrowseBySubjectsComponent], + providers: [provideOSFCore(), MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().build())], + }); fixture = TestBed.createComponent(BrowseBySubjectsComponent); component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.componentRef.setInput('subjects', []); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - + fixture.componentRef.setInput('subjects', overrides?.subjects ?? []); + fixture.componentRef.setInput('areSubjectsLoading', overrides?.areSubjectsLoading ?? false); + fixture.componentRef.setInput('isProviderLoading', overrides?.isProviderLoading ?? false); + fixture.componentRef.setInput('isLandingPage', overrides?.isLandingPage ?? false); fixture.detectChanges(); - expect(component).toBeTruthy(); - }); + } - it('should have default input values', () => { - fixture.componentRef.setInput('subjects', []); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - fixture.detectChanges(); + it('should keep default isLandingPage input as false', () => { + setup(); - expect(component.subjects()).toEqual([]); - expect(component.areSubjectsLoading()).toBe(false); - expect(component.isProviderLoading()).toBe(false); expect(component.isLandingPage()).toBe(false); }); - it('should display title', () => { - fixture.componentRef.setInput('subjects', []); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const title = compiled.querySelector('h2'); + it('should render skeleton rows while loading', () => { + setup({ areSubjectsLoading: true, subjects: mockSubjects }); - expect(title).toBeTruthy(); - expect(title.textContent).toBe('preprints.browseBySubjects.title'); + expect(fixture.nativeElement.querySelectorAll('p-skeleton').length).toBe(6); + expect(fixture.nativeElement.querySelectorAll('p-button').length).toBe(0); }); - it('should display correct subject names in buttons', () => { - fixture.componentRef.setInput('subjects', mockSubjects); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const buttons = compiled.querySelectorAll('p-button'); + it('should render one button per subject when not loading', () => { + setup({ subjects: mockSubjects }); - expect(buttons[0].getAttribute('ng-reflect-label')).toBe('Mathematics'); - expect(buttons[1].getAttribute('ng-reflect-label')).toBe('Physics'); + expect(fixture.nativeElement.querySelectorAll('p-button').length).toBe(mockSubjects.length); }); - it('should compute linksToSearchPageForSubject correctly', () => { - fixture.componentRef.setInput('subjects', mockSubjects); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - fixture.detectChanges(); - - const links = component.linksToSearchPageForSubject(); + it('should build query params for subject with iri', () => { + setup({ subjects: mockSubjects }); - expect(links).toHaveLength(2); - expect(links[0]).toEqual({ + expect(component.getQueryParamsForSubject(mockSubjects[0])).toEqual({ tab: ResourceType.Preprint, filter_subject: '[{"label":"Mathematics","value":"https://example.com/subjects/mathematics"}]', }); - expect(links[1]).toEqual({ - tab: ResourceType.Preprint, - filter_subject: '[{"label":"Physics","value":"https://example.com/subjects/physics"}]', - }); }); - it('should set correct routerLink for non-landing page', () => { - fixture.componentRef.setInput('subjects', mockSubjects); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - fixture.componentRef.setInput('isLandingPage', false); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const buttons = compiled.querySelectorAll('p-button'); - - expect(buttons[0].getAttribute('ng-reflect-router-link')).toBe('discover'); - }); - - it('should set correct routerLink for landing page', () => { - fixture.componentRef.setInput('subjects', mockSubjects); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - fixture.componentRef.setInput('isLandingPage', true); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const buttons = compiled.querySelectorAll('p-button'); - - expect(buttons[0].getAttribute('ng-reflect-router-link')).toBe('/search'); - }); - - it('should handle subjects without iri', () => { - const subjectsWithoutIri: SubjectModel[] = [ - { - id: 'subject-1', - name: 'Physics', - iri: undefined, - children: [], - parent: null, - expanded: false, - }, - ]; - - fixture.componentRef.setInput('subjects', subjectsWithoutIri); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - fixture.detectChanges(); - - const links = component.linksToSearchPageForSubject(); - - expect(links).toHaveLength(1); - expect(links[0]).toEqual({ + it('should build query params for subject without iri', () => { + setup(); + const subjectWithoutIri = { + id: 'subject-1', + name: 'Physics', + iri: undefined, + children: [], + parent: null, + expanded: false, + } as SubjectModel; + + expect(component.getQueryParamsForSubject(subjectWithoutIri)).toEqual({ tab: ResourceType.Preprint, filter_subject: '[{"label":"Physics"}]', }); }); + + it.each([ + { isLandingPage: false, expected: 'discover' }, + { isLandingPage: true, expected: '/search' }, + ])('should resolve route for isLandingPage=$isLandingPage', ({ isLandingPage, expected }) => { + setup({ isLandingPage }); + + expect(component.subjectRoute()).toBe(expected); + }); }); diff --git a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts index c701c73c4..6833c042b 100644 --- a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts +++ b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts @@ -6,20 +6,28 @@ import { Skeleton } from 'primeng/skeleton'; import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { RouterLink } from '@angular/router'; -import { ResourceType } from '@shared/enums/resource-type.enum'; -import { SubjectModel } from '@shared/models/subject/subject.model'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { SubjectModel } from '@osf/shared/models/subject/subject.model'; @Component({ selector: 'osf-browse-by-subjects', - imports: [RouterLink, Skeleton, TranslatePipe, Button], + imports: [Button, Skeleton, RouterLink, TranslatePipe], templateUrl: './browse-by-subjects.component.html', styleUrl: './browse-by-subjects.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class BrowseBySubjectsComponent { - subjects = input.required(); - linksToSearchPageForSubject = computed(() => { - return this.subjects().map((subject) => ({ + readonly subjects = input.required(); + readonly areSubjectsLoading = input.required(); + readonly isProviderLoading = input.required(); + readonly isLandingPage = input(false); + + readonly skeletonArray = new Array(6); + + readonly subjectRoute = computed(() => (this.isLandingPage() ? '/search' : 'discover')); + + getQueryParamsForSubject(subject: SubjectModel) { + return { tab: ResourceType.Preprint, filter_subject: JSON.stringify([ { @@ -27,10 +35,6 @@ export class BrowseBySubjectsComponent { value: subject.iri, }, ]), - })); - }); - areSubjectsLoading = input.required(); - isProviderLoading = input.required(); - isLandingPage = input(false); - skeletonArray = Array.from({ length: 6 }, (_, i) => i + 1); + }; + } } diff --git a/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.spec.ts b/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.spec.ts index b2c0e251c..14c6b329f 100644 --- a/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.spec.ts +++ b/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.spec.ts @@ -3,52 +3,29 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PreprintProviderFooterComponent } from './preprint-provider-footer.component'; describe('PreprintProviderFooterComponent', () => { - let component: PreprintProviderFooterComponent; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ + beforeEach(() => { + TestBed.configureTestingModule({ imports: [PreprintProviderFooterComponent], - }).compileComponents(); + }); fixture = TestBed.createComponent(PreprintProviderFooterComponent); - component = fixture.componentInstance; }); - it('should create', () => { - fixture.componentRef.setInput('footerHtml', ''); + it('should render section when footerHtml has content', () => { + fixture.componentRef.setInput('footerHtml', '

Footer

'); fixture.detectChanges(); - expect(component).toBeTruthy(); + const section = fixture.nativeElement.querySelector('section'); + expect(section).not.toBeNull(); + expect(section.innerHTML).toContain('Footer'); }); - it('should not render section when footerHtml is null', () => { - fixture.componentRef.setInput('footerHtml', null); + it.each([null, undefined, ''])('should not render section when footerHtml is %p', (value) => { + fixture.componentRef.setInput('footerHtml', value); fixture.detectChanges(); - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); - - expect(section).toBeNull(); - }); - - it('should not render section when footerHtml is undefined', () => { - fixture.componentRef.setInput('footerHtml', undefined); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); - - expect(section).toBeNull(); - }); - - it('should not render section when footerHtml is empty string', () => { - fixture.componentRef.setInput('footerHtml', ''); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); - - expect(section).toBeNull(); + expect(fixture.nativeElement.querySelector('section')).toBeNull(); }); }); diff --git a/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.ts b/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.ts index 6b7e2fa65..da4d77af4 100644 --- a/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.ts +++ b/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.ts @@ -11,5 +11,5 @@ import { SafeHtmlPipe } from '@osf/shared/pipes/safe-html.pipe'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintProviderFooterComponent { - footerHtml = input.required(); + readonly footerHtml = input(null); } diff --git a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.html b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.html index 877aefcc0..abde6fa7e 100644 --- a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.html +++ b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.html @@ -1,19 +1,21 @@ +@let provider = preprintProvider(); +
@if (isPreprintProviderLoading()) { - } @else { -

{{ preprintProvider()!.name }}

+ } @else if (provider) { +

{{ provider.name }}

}
@if (isPreprintProviderLoading()) { - } @else { + } @else if (provider) { } @@ -21,12 +23,12 @@

{{ preprintProvider()!.name }}

@if (isPreprintProviderLoading()) { - } @else if (preprintProvider()!.allowSubmissions) { + } @else if (provider?.allowSubmissions) { }
@@ -39,8 +41,8 @@

{{ preprintProvider()!.name }}

- } @else { -
+ } @else if (provider) { +
{{ 'preprints.poweredBy' | translate }} }
@@ -51,9 +53,7 @@

{{ preprintProvider()!.name }}

{{ preprintProvider()!.name }} @if (isPreprintProviderLoading()) { - } @else if (preprintProvider()?.examplePreprintId) { -

- {{ 'preprints.showExample' | translate }} - -

+ } @else if (provider?.examplePreprintId) { + + {{ 'preprints.showExample' | translate }} + }
diff --git a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.scss b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.scss index 86cfbe725..e69de29bb 100644 --- a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.scss +++ b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.scss @@ -1,12 +0,0 @@ -@use "styles/mixins" as mix; - -.search-input-container { - position: relative; - - img { - position: absolute; - right: mix.rem(4px); - top: mix.rem(4px); - z-index: 1; - } -} diff --git a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.spec.ts b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.spec.ts index dbb62042e..4b3444fa5 100644 --- a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.spec.ts +++ b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.spec.ts @@ -1,177 +1,118 @@ -import { MockComponent, MockProvider } from 'ng-mocks'; - -import { DialogService } from 'primeng/dynamicdialog'; +import { MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { PreprintProviderDetails } from '@osf/features/preprints/models'; -import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; + +import { PreprintsHelpDialogComponent } from '../preprints-help-dialog/preprints-help-dialog.component'; import { PreprintProviderHeroComponent } from './preprint-provider-hero.component'; 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 { DialogServiceMockBuilder } from '@testing/providers/dialog-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; describe('PreprintProviderHeroComponent', () => { let component: PreprintProviderHeroComponent; let fixture: ComponentFixture; - let mockDialogService: ReturnType; + let customDialogMock: CustomDialogServiceMockType; const mockPreprintProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; - beforeEach(async () => { - mockDialogService = DialogServiceMockBuilder.create().build(); + function setup(overrides?: { + searchControl?: FormControl; + preprintProvider?: PreprintProviderDetails | undefined; + isPreprintProviderLoading?: boolean; + }) { + customDialogMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - await TestBed.configureTestingModule({ - imports: [PreprintProviderHeroComponent, OSFTestingModule, MockComponent(SearchInputComponent)], + TestBed.configureTestingModule({ + imports: [PreprintProviderHeroComponent], providers: [ - MockProvider(DialogService, mockDialogService), + provideOSFCore(), + MockProvider(CustomDialogService, customDialogMock), MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().build()), - TranslationServiceMock, ], - }) - .overrideComponent(PreprintProviderHeroComponent, { - set: { - providers: [{ provide: DialogService, useValue: mockDialogService }], - }, - }) - .compileComponents(); + }); fixture = TestBed.createComponent(PreprintProviderHeroComponent); component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); + fixture.componentRef.setInput( + 'searchControl', + overrides && 'searchControl' in overrides ? overrides.searchControl : new FormControl('', { nonNullable: true }) + ); + fixture.componentRef.setInput( + 'preprintProvider', + overrides && 'preprintProvider' in overrides ? overrides.preprintProvider : mockPreprintProvider + ); + fixture.componentRef.setInput( + 'isPreprintProviderLoading', + overrides && 'isPreprintProviderLoading' in overrides ? overrides.isPreprintProviderLoading : false + ); fixture.detectChanges(); + } - expect(component).toBeTruthy(); - }); + function query(selector: string): Element | null { + return fixture.nativeElement.querySelector(selector); + } - it('should display loading skeletons when isPreprintProviderLoading is true', () => { - fixture.componentRef.setInput('isPreprintProviderLoading', true); - fixture.detectChanges(); + it('should show skeletons while loading', () => { + setup({ isPreprintProviderLoading: true, preprintProvider: undefined }); - const compiled = fixture.nativeElement; - const skeletons = compiled.querySelectorAll('p-skeleton'); - const providerName = compiled.querySelector('.preprint-provider-name'); - const providerLogo = compiled.querySelector('img'); - const addButton = compiled.querySelector('p-button'); - const searchInput = compiled.querySelector('osf-search-input'); - - expect(skeletons.length).toBeGreaterThan(0); - expect(providerName).toBeNull(); - expect(providerLogo).toBeNull(); - expect(addButton).toBeNull(); - expect(searchInput).toBeNull(); + expect(fixture.nativeElement.querySelectorAll('p-skeleton').length).toBeGreaterThan(0); + expect(query('.preprint-provider-name')).toBeNull(); + expect(query('img')).toBeNull(); + expect(query('p-button')).toBeNull(); + expect(query('osf-search-input')).toBeNull(); }); - it('should display provider information when not loading', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); + it('should render provider info when not loading', () => { + setup(); - const compiled = fixture.nativeElement; - const providerName = compiled.querySelector('.preprint-provider-name'); - const providerLogo = compiled.querySelector('img'); - const description = compiled.querySelector('.provider-description div'); - - expect(providerName).toBeTruthy(); - expect(providerName?.textContent).toBe('OSF Preprints'); - expect(providerLogo).toBeTruthy(); - expect(providerLogo?.getAttribute('src')).toBe('https://osf.io/assets/hero-logo.png'); - expect(description).toBeTruthy(); - expect(description?.innerHTML).toContain('

Open preprints for all disciplines

'); + expect(query('.preprint-provider-name')?.textContent).toBe('OSF Preprints'); + expect((query('img') as HTMLImageElement).getAttribute('src')).toBe('https://osf.io/assets/hero-logo.png'); + expect(query('.provider-description div')?.innerHTML).toContain('

Open preprints for all disciplines

'); }); - it('should display add preprint button when allowSubmissions is true', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const addButton = compiled.querySelector('p-button'); + it('should hide provider-dependent content when provider is undefined and not loading', () => { + setup({ preprintProvider: undefined, isPreprintProviderLoading: false }); - expect(addButton).toBeTruthy(); - expect(addButton?.getAttribute('ng-reflect-label')).toBe('Preprints.addpreprint'); - expect(addButton?.getAttribute('ng-reflect-router-link')).toBe('/preprints,osf-preprints,submi'); + expect(query('.preprint-provider-name')).toBeNull(); + expect(query('img')).toBeNull(); }); - it('should display search input when not loading', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); + it('should emit normalized search value', () => { + setup(); + jest.spyOn(component.triggerSearch, 'emit'); - const compiled = fixture.nativeElement; - const searchInput = compiled.querySelector('osf-search-input'); + component.onTriggerSearch('test “quoted” value'); - expect(searchInput).toBeTruthy(); - expect(searchInput?.getAttribute('ng-reflect-show-help-icon')).toBe('true'); - expect(searchInput?.getAttribute('ng-reflect-placeholder')).toBe('Preprints.searchplaceholder'); + expect(component.triggerSearch.emit).toHaveBeenCalledWith('test "quoted" value'); }); - it('should emit triggerSearch when onTriggerSearch is called', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); - + it('should emit empty string when search value is missing', () => { + setup(); jest.spyOn(component.triggerSearch, 'emit'); - const searchValue = 'test search query'; - component.onTriggerSearch(searchValue); + component.onTriggerSearch(undefined as unknown as string); - expect(component.triggerSearch.emit).toHaveBeenCalledWith(searchValue); + expect(component.triggerSearch.emit).toHaveBeenCalledWith(''); }); - it('should open help dialog when openHelpDialog is called', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); - - expect(mockDialogService.open).toBeDefined(); - expect(typeof mockDialogService.open).toBe('function'); + it('should open help dialog with expected header', () => { + setup(); component.openHelpDialog(); - expect(mockDialogService.open).toHaveBeenCalledWith(expect.any(Function), { - focusOnShow: false, + expect(customDialogMock.open).toHaveBeenCalledWith(PreprintsHelpDialogComponent, { header: 'preprints.helpDialog.header', - closeOnEscape: true, - modal: true, - closable: true, - breakpoints: { '768px': '95vw' }, }); }); - - it('should update when input properties change', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', undefined); - fixture.componentRef.setInput('isPreprintProviderLoading', true); - fixture.detectChanges(); - - let compiled = fixture.nativeElement; - expect(compiled.querySelectorAll('p-skeleton').length).toBeGreaterThan(0); - expect(compiled.querySelector('.preprint-provider-name')).toBeNull(); - - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); - - compiled = fixture.nativeElement; - expect(compiled.querySelectorAll('p-skeleton').length).toBe(0); - expect(compiled.querySelector('.preprint-provider-name')).toBeTruthy(); - expect(compiled.querySelector('.preprint-provider-name')?.textContent).toBe('OSF Preprints'); - }); }); diff --git a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.ts b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.ts index 826d17eca..2fa2bc2aa 100644 --- a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.ts +++ b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.ts @@ -18,24 +18,24 @@ import { PreprintsHelpDialogComponent } from '../preprints-help-dialog/preprints @Component({ selector: 'osf-preprint-provider-hero', - imports: [Button, RouterLink, SearchInputComponent, Skeleton, TranslatePipe, TitleCasePipe, SafeHtmlPipe], + imports: [Button, Skeleton, RouterLink, SearchInputComponent, SafeHtmlPipe, TitleCasePipe, TranslatePipe], templateUrl: './preprint-provider-hero.component.html', styleUrl: './preprint-provider-hero.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintProviderHeroComponent { - customDialogService = inject(CustomDialogService); + private readonly customDialogService = inject(CustomDialogService); - searchControl = input(new FormControl()); - preprintProvider = input.required(); - isPreprintProviderLoading = input.required(); - triggerSearch = output(); + readonly searchControl = input>(new FormControl('', { nonNullable: true })); + readonly isPreprintProviderLoading = input.required(); + readonly preprintProvider = input(); + readonly triggerSearch = output(); - onTriggerSearch(value: string) { - this.triggerSearch.emit(normalizeQuotes(value)!); + onTriggerSearch(value: string): void { + this.triggerSearch.emit(normalizeQuotes(value) ?? ''); } - openHelpDialog() { + openHelpDialog(): void { this.customDialogService.open(PreprintsHelpDialogComponent, { header: 'preprints.helpDialog.header' }); } } diff --git a/src/app/features/preprints/components/preprint-services/preprint-services.component.spec.ts b/src/app/features/preprints/components/preprint-services/preprint-services.component.spec.ts index fd76e1ec9..552053787 100644 --- a/src/app/features/preprints/components/preprint-services/preprint-services.component.spec.ts +++ b/src/app/features/preprints/components/preprint-services/preprint-services.component.spec.ts @@ -5,7 +5,7 @@ import { PreprintProviderShortInfo } from '@osf/features/preprints/models'; import { PreprintServicesComponent } from './preprint-services.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'; describe('PreprintServicesComponent', () => { let component: PreprintServicesComponent; @@ -13,32 +13,30 @@ describe('PreprintServicesComponent', () => { const mockProviders: PreprintProviderShortInfo[] = [PREPRINT_PROVIDER_SHORT_INFO_MOCK]; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PreprintServicesComponent, OSFTestingModule], - }).compileComponents(); + function setup(providers: PreprintProviderShortInfo[]) { + TestBed.configureTestingModule({ + imports: [PreprintServicesComponent], + providers: [provideOSFCore()], + }); fixture = TestBed.createComponent(PreprintServicesComponent); component = fixture.componentInstance; - }); + fixture.componentRef.setInput('preprintProvidersToAdvertise', providers); + } it('should create', () => { - fixture.componentRef.setInput('preprintProvidersToAdvertise', []); - fixture.detectChanges(); - + setup(mockProviders); expect(component).toBeTruthy(); }); - it('should accept preprint providers input', () => { - fixture.componentRef.setInput('preprintProvidersToAdvertise', mockProviders); - fixture.detectChanges(); + it('should keep provided providers input', () => { + setup(mockProviders); expect(component.preprintProvidersToAdvertise()).toEqual(mockProviders); }); - it('should handle empty providers array', () => { - fixture.componentRef.setInput('preprintProvidersToAdvertise', []); - fixture.detectChanges(); + it('should keep empty providers input', () => { + setup([]); expect(component.preprintProvidersToAdvertise()).toEqual([]); }); diff --git a/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.html b/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.html index ef1b931d3..9669136c1 100644 --- a/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.html +++ b/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.html @@ -1,8 +1,8 @@

{{ 'preprints.helpDialog.message' | translate }} - {{ 'preprints.helpDialog.linkText' | translate }}. + {{ 'preprints.helpDialog.linkText' | translate }}.

diff --git a/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.spec.ts b/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.spec.ts index dfe20d050..ccd92cbc7 100644 --- a/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.spec.ts +++ b/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.spec.ts @@ -2,16 +2,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PreprintsHelpDialogComponent } from './preprints-help-dialog.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('PreprintsHelpDialogComponent', () => { let component: PreprintsHelpDialogComponent; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PreprintsHelpDialogComponent, OSFTestingModule], - }).compileComponents(); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [PreprintsHelpDialogComponent], + providers: [provideOSFCore()], + }); fixture = TestBed.createComponent(PreprintsHelpDialogComponent); component = fixture.componentInstance; diff --git a/src/app/features/preprints/guards/index.ts b/src/app/features/preprints/guards/index.ts deleted file mode 100644 index 1b75c6aed..000000000 --- a/src/app/features/preprints/guards/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './preprints-moderator.guard'; diff --git a/src/app/features/preprints/guards/preprints-moderator.guard.spec.ts b/src/app/features/preprints/guards/preprints-moderator.guard.spec.ts new file mode 100644 index 000000000..45d1e29a8 --- /dev/null +++ b/src/app/features/preprints/guards/preprints-moderator.guard.spec.ts @@ -0,0 +1,53 @@ +import { MockProvider } from 'ng-mocks'; + +import { runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; + +import { UserSelectors } from '@core/store/user'; + +import { preprintsModeratorGuard } from './preprints-moderator.guard'; + +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('preprintsModeratorGuard', () => { + let routerMock: RouterMockType; + const routeSnapshot = {} as ActivatedRouteSnapshot; + const stateSnapshot = {} as RouterStateSnapshot; + + function setup(canViewReviews: boolean) { + const urlTree = {} as UrlTree; + + routerMock = RouterMockBuilder.create().withCreateUrlTree(jest.fn().mockReturnValue(urlTree)).build(); + + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [{ selector: UserSelectors.getCanViewReviews, value: canViewReviews }], + }), + MockProvider(Router, routerMock), + ], + }); + + return { urlTree }; + } + + it('should allow activation when user can view reviews', () => { + setup(true); + + const result = runInInjectionContext(TestBed, () => preprintsModeratorGuard(routeSnapshot, stateSnapshot)); + + expect(result).toBe(true); + expect(routerMock.createUrlTree).not.toHaveBeenCalled(); + }); + + it('should return forbidden UrlTree when user cannot view reviews', () => { + const { urlTree } = setup(false); + + const result = runInInjectionContext(TestBed, () => preprintsModeratorGuard(routeSnapshot, stateSnapshot)); + + expect(routerMock.createUrlTree).toHaveBeenCalledWith(['/forbidden']); + expect(result).toBe(urlTree); + }); +}); diff --git a/src/app/features/preprints/guards/preprints-moderator.guard.ts b/src/app/features/preprints/guards/preprints-moderator.guard.ts index 4a3b625eb..a6fba2a91 100644 --- a/src/app/features/preprints/guards/preprints-moderator.guard.ts +++ b/src/app/features/preprints/guards/preprints-moderator.guard.ts @@ -12,8 +12,8 @@ export const preprintsModeratorGuard: CanActivateFn = () => { const canUserViewReviews = store.selectSnapshot(UserSelectors.getCanViewReviews); if (!canUserViewReviews) { - router.navigateByUrl('/forbidden'); + return router.createUrlTree(['/forbidden']); } - return canUserViewReviews; + return true; }; 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 590c4972b..d9cc1d1a1 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 @@ -350,6 +350,14 @@ describe('PreprintDetailsComponent', () => { expect(component.editButtonVisible()).toBe(false); }); + it('should hide edit button when user does not have write access', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.hasWriteAccess, value: false }], + }); + + expect(component.editButtonVisible()).toBe(false); + }); + it('should show edit button for initial preprint', () => { setup({ selectorOverrides: [ diff --git a/src/app/features/preprints/services/preprint-files.service.ts b/src/app/features/preprints/services/preprint-files.service.ts index aa2a0cad1..0f0de7945 100644 --- a/src/app/features/preprints/services/preprint-files.service.ts +++ b/src/app/features/preprints/services/preprint-files.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { map, Observable, switchMap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; @@ -18,7 +17,6 @@ import { FileFolderResponseJsonApi, FileFoldersResponseJsonApi, } from '@osf/shared/models/files/file-folder-json-api.model'; -import { FilesService } from '@osf/shared/services/files.service'; import { JsonApiService } from '@osf/shared/services/json-api.service'; import { FilesMapper } from '@shared/mappers/files/files.mapper'; @@ -26,8 +24,7 @@ import { FilesMapper } from '@shared/mappers/files/files.mapper'; providedIn: 'root', }) export class PreprintFilesService { - private filesService = inject(FilesService); - private jsonApiService = inject(JsonApiService); + private readonly jsonApiService = inject(JsonApiService); private readonly environment = inject(ENVIRONMENT); get apiUrl() { @@ -58,7 +55,7 @@ export class PreprintFilesService { } getPreprintFilesLinks(id: string): Observable { - return this.jsonApiService.get(`${this.apiUrl}/preprints/${id}/files/`).pipe( + return this.jsonApiService.get(`${this.apiUrl}/preprints/${id}/files/`).pipe( map((response) => { const rel = response.data[0].relationships; const links = response.data[0].links; @@ -72,12 +69,14 @@ export class PreprintFilesService { } getProjectRootFolder(projectId: string): Observable { - return this.jsonApiService.get(`${this.apiUrl}/nodes/${projectId}/files/`).pipe( - switchMap((response: FileFoldersResponseJsonApi) => { - return this.jsonApiService - .get(response.data[0].relationships.root_folder.links.related.href) - .pipe(map((folder) => FilesMapper.getFileFolder(folder.data))); - }) - ); + return this.jsonApiService + .get(`${this.apiUrl}/nodes/${projectId}/files/`) + .pipe( + switchMap((response) => + this.jsonApiService + .get(response.data[0].relationships.root_folder.links.related.href) + .pipe(map((folder) => FilesMapper.getFileFolder(folder.data))) + ) + ); } } From 1325bc99ba97650f63746cfca456ee8eb41298e9 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Tue, 10 Mar 2026 19:21:43 +0200 Subject: [PATCH 24/27] [ENG-10529] Users can still submit to registries that are closed to new submissions (#903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ticket: https://openscience.atlassian.net/browse/ENG-10529 - Feature flag: n/a ## Purpose In admin, the Product Team can determine when a registry (and other services) are open for new submissions. When closed, a user would see no “Add registration” button when on the registration pages, and direct submission links (ie https://osf.io/registries/dataarchive/new) will throw a not allowed error and not allow submissions. However, registries closed to submissions can be submitted to freely. This is a misleading status, and confusing for users and members. For example, the Character Lab Registry has been closed to submission for several years but is now available for submissions: https://osf.io/registries/characterlabregistry ## Summary of Changes Not render `Add Registration` button if `allow_submissions` is set to false in admin redirect and show error message if on link /new access `allow_submissions` is set to false --- .../new-registration.component.html | 148 +++++++++--------- .../new-registration.component.spec.ts | 94 ++++++++--- .../new-registration.component.ts | 22 ++- .../registry-provider-hero.component.html | 12 +- .../mappers/registration-provider.mapper.ts | 1 + .../provider/registry-provider.model.ts | 1 + src/assets/i18n/en.json | 3 +- 7 files changed, 183 insertions(+), 98 deletions(-) diff --git a/src/app/features/registries/components/new-registration/new-registration.component.html b/src/app/features/registries/components/new-registration/new-registration.component.html index 7c46cb5e9..2037844a7 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.html +++ b/src/app/features/registries/components/new-registration/new-registration.component.html @@ -1,86 +1,92 @@ -
- +@if (canShowForm()) { +
+ -
-

- {{ 'registries.new.infoText1' | translate }} - {{ 'common.links.clickHere' | translate }} - {{ 'registries.new.infoText2' | translate }} -

-
+
+

+ {{ 'registries.new.infoText1' | translate }} + {{ 'common.links.clickHere' | translate }} + {{ 'registries.new.infoText2' | translate }} +

+
+ +
+ +

{{ 'registries.new.steps.title' | translate }} 1

+

{{ 'registries.new.steps.step1' | translate }}

+
+ + +
+
-
- -

{{ 'registries.new.steps.title' | translate }} 1

-

{{ 'registries.new.steps.step1' | translate }}

-
- - -
-
+ + @if (fromProject()) { + +

{{ 'registries.new.steps.title' | translate }} 2

+

{{ 'registries.new.steps.step2' | translate }}

+

{{ 'registries.new.steps.step2InfoText' | translate }}

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

{{ 'registries.new.steps.title' | translate }} 2

-

{{ 'registries.new.steps.step2' | translate }}

-

{{ 'registries.new.steps.step2InfoText' | translate }}

+

{{ 'registries.new.steps.title' | translate }} {{ fromProject() ? '3' : '2' }}

+

{{ 'registries.new.steps.step3' | translate }}

- } - -

{{ 'registries.new.steps.title' | translate }} {{ fromProject() ? '3' : '2' }}

-

{{ 'registries.new.steps.step3' | translate }}

-
- +
-
- -
- -
- + +
-
+} @else { +
+ +
+} diff --git a/src/app/features/registries/components/new-registration/new-registration.component.spec.ts b/src/app/features/registries/components/new-registration/new-registration.component.spec.ts index c06634a3a..80246925c 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.spec.ts +++ b/src/app/features/registries/components/new-registration/new-registration.component.spec.ts @@ -9,7 +9,7 @@ import { UserSelectors } from '@core/store/user'; import { CreateDraft, GetProjects, GetProviderSchemas, RegistriesSelectors } from '@osf/features/registries/store'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ToastService } from '@osf/shared/services/toast.service'; -import { GetRegistryProvider } from '@shared/stores/registration-provider'; +import { GetRegistryProvider, RegistrationProviderSelectors } from '@shared/stores/registration-provider'; import { NewRegistrationComponent } from './new-registration.component'; @@ -17,38 +17,53 @@ import { MOCK_PROVIDER_SCHEMAS } from '@testing/mocks/registries.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('NewRegistrationComponent', () => { let component: NewRegistrationComponent; let fixture: ComponentFixture; let store: Store; let mockRouter: RouterMockType; - - beforeEach(() => { + let toastService: ToastServiceMockType; + + interface SetupOverrides extends BaseSetupOverrides { + selectorOverrides?: SignalOverride[]; + } + + const defaultSignals: SignalOverride[] = [ + { selector: RegistriesSelectors.getProjects, value: [{ id: 'p1', title: 'P1' }] }, + { selector: RegistriesSelectors.getProviderSchemas, value: MOCK_PROVIDER_SCHEMAS }, + { selector: RegistriesSelectors.isDraftSubmitting, value: false }, + { selector: RegistriesSelectors.getDraftRegistration, value: { id: 'draft-1' } }, + { selector: RegistriesSelectors.isProvidersLoading, value: false }, + { selector: RegistriesSelectors.isProjectsLoading, value: false }, + { selector: UserSelectors.getCurrentUser, value: { id: 'user-1' } }, + { selector: RegistrationProviderSelectors.getBrandedProvider, value: { id: 'prov-1', allowSubmissions: true } }, + ]; + + const setup = (overrides?: SetupOverrides) => { const mockActivatedRoute = ActivatedRouteMockBuilder.create() - .withParams({ providerId: 'prov-1' }) + .withParams(overrides?.routeParams || { providerId: 'prov-1' }) .withQueryParams({ projectId: 'proj-1' }) .build(); mockRouter = RouterMockBuilder.create().withUrl('/x').build(); + toastService = ToastServiceMock.simple(); TestBed.configureTestingModule({ imports: [NewRegistrationComponent, MockComponent(SubHeaderComponent)], providers: [ provideOSFCore(), MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(ToastService), + MockProvider(ToastService, toastService), MockProvider(Router, mockRouter), provideMockStore({ - signals: [ - { selector: RegistriesSelectors.getProjects, value: [{ id: 'p1', title: 'P1' }] }, - { selector: RegistriesSelectors.getProviderSchemas, value: MOCK_PROVIDER_SCHEMAS }, - { selector: RegistriesSelectors.isDraftSubmitting, value: false }, - { selector: RegistriesSelectors.getDraftRegistration, value: { id: 'draft-1' } }, - { selector: RegistriesSelectors.isProvidersLoading, value: false }, - { selector: RegistriesSelectors.isProjectsLoading, value: false }, - { selector: UserSelectors.getCurrentUser, value: { id: 'user-1' } }, - ], + signals: mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides), }), ], }); @@ -57,31 +72,69 @@ describe('NewRegistrationComponent', () => { fixture = TestBed.createComponent(NewRegistrationComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + }; it('should create', () => { + setup(); expect(component).toBeTruthy(); }); + it('should allow submissions when provider allows it', () => { + setup(); + expect(component.canShowForm()).toBe(true); + expect(toastService.showError).not.toHaveBeenCalled(); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should redirect and show error when submissions are not allowed', () => { + setup({ + selectorOverrides: [ + { + selector: RegistrationProviderSelectors.getBrandedProvider, + value: { id: 'prov-1', allowSubmissions: false }, + }, + ], + }); + + expect(component.canShowForm()).toBe(false); + expect(toastService.showError).toHaveBeenCalledWith('registries.new.registryClosedForSubmissions'); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries', 'prov-1']); + }); + + it('should redirect and show error when allowSubmissions is undefined', () => { + setup({ + selectorOverrides: [{ selector: RegistrationProviderSelectors.getBrandedProvider, value: { id: 'prov-1' } }], + }); + + expect(component.canShowForm()).toBe(false); + expect(toastService.showError).toHaveBeenCalledWith('registries.new.registryClosedForSubmissions'); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries', 'prov-1']); + }); + it('should dispatch initial data fetching on init', () => { + setup(); expect(store.dispatch).toHaveBeenCalledWith(new GetProjects('user-1', '')); expect(store.dispatch).toHaveBeenCalledWith(new GetRegistryProvider('prov-1')); expect(store.dispatch).toHaveBeenCalledWith(new GetProviderSchemas('prov-1')); }); it('should init fromProject as true when projectId is present', () => { + setup(); expect(component.fromProject()).toBe(true); }); it('should init form with project id from route', () => { + setup(); expect(component.draftForm.get('project')?.value).toBe('proj-1'); }); it('should default providerSchema when schemas are available', () => { + setup(); expect(component.draftForm.get('providerSchema')?.value).toBe('schema-1'); }); it('should toggle fromProject and add/remove validator', () => { + setup(); component.fromProject.set(false); component.toggleFromProject(); expect(component.fromProject()).toBe(true); @@ -93,6 +146,7 @@ describe('NewRegistrationComponent', () => { }); it('should dispatch createDraft and navigate when form is valid', () => { + setup(); component.draftForm.patchValue({ providerSchema: 'schema-1', project: 'proj-1' }); component.fromProject.set(true); (store.dispatch as jest.Mock).mockClear(); @@ -106,6 +160,7 @@ describe('NewRegistrationComponent', () => { }); it('should not dispatch createDraft when form is invalid', () => { + setup(); component.draftForm.patchValue({ providerSchema: '' }); (store.dispatch as jest.Mock).mockClear(); @@ -115,6 +170,7 @@ describe('NewRegistrationComponent', () => { }); it('should dispatch getProjects after debounced filter', fakeAsync(() => { + setup(); (store.dispatch as jest.Mock).mockClear(); component.onProjectFilter('abc'); @@ -124,6 +180,7 @@ describe('NewRegistrationComponent', () => { })); it('should not dispatch duplicate getProjects for same filter value', fakeAsync(() => { + setup(); (store.dispatch as jest.Mock).mockClear(); component.onProjectFilter('abc'); @@ -132,12 +189,13 @@ describe('NewRegistrationComponent', () => { tick(300); const getProjectsCalls = (store.dispatch as jest.Mock).mock.calls.filter( - ([action]: [any]) => action instanceof GetProjects + ([action]: [unknown]) => action instanceof GetProjects ); expect(getProjectsCalls.length).toBe(1); })); it('should debounce rapid filter calls and dispatch only the last value', fakeAsync(() => { + setup(); (store.dispatch as jest.Mock).mockClear(); component.onProjectFilter('a'); @@ -146,7 +204,7 @@ describe('NewRegistrationComponent', () => { tick(300); const getProjectsCalls = (store.dispatch as jest.Mock).mock.calls.filter( - ([action]: [any]) => action instanceof GetProjects + ([action]: [unknown]) => action instanceof GetProjects ); expect(getProjectsCalls.length).toBe(1); expect(getProjectsCalls[0][0]).toEqual(new GetProjects('user-1', 'abc')); diff --git a/src/app/features/registries/components/new-registration/new-registration.component.ts b/src/app/features/registries/components/new-registration/new-registration.component.ts index 62e4b8e61..8fc36948b 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.ts +++ b/src/app/features/registries/components/new-registration/new-registration.component.ts @@ -8,21 +8,22 @@ import { Select } from 'primeng/select'; import { debounceTime, distinctUntilChanged, filter, Subject, take } from 'rxjs'; -import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; +import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ToastService } from '@osf/shared/services/toast.service'; -import { GetRegistryProvider } from '@shared/stores/registration-provider'; +import { GetRegistryProvider, RegistrationProviderSelectors } from '@shared/stores/registration-provider'; import { CreateDraft, GetProjects, GetProviderSchemas, RegistriesSelectors } from '../../store'; @Component({ selector: 'osf-new-registration', - imports: [SubHeaderComponent, TranslatePipe, Card, Button, ReactiveFormsModule, Select], + imports: [Button, Card, Select, ReactiveFormsModule, LoadingSpinnerComponent, SubHeaderComponent, TranslatePipe], templateUrl: './new-registration.component.html', styleUrl: './new-registration.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -37,11 +38,14 @@ export class NewRegistrationComponent { readonly user = select(UserSelectors.getCurrentUser); readonly projects = select(RegistriesSelectors.getProjects); readonly providerSchemas = select(RegistriesSelectors.getProviderSchemas); + readonly provider = select(RegistrationProviderSelectors.getBrandedProvider); readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); readonly isProvidersLoading = select(RegistriesSelectors.isProvidersLoading); readonly isProjectsLoading = select(RegistriesSelectors.isProjectsLoading); private readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + readonly canShowForm = computed(() => !this.isProvidersLoading() && !!this.provider()?.allowSubmissions); + private readonly actions = createDispatchMap({ getProvider: GetRegistryProvider, getProjects: GetProjects, @@ -62,6 +66,7 @@ export class NewRegistrationComponent { this.loadInitialData(); this.setupDefaultSchema(); this.setupProjectFilter(); + this.setupSubmissionsAccessCheck(); } onProjectFilter(value: string) { @@ -123,4 +128,15 @@ export class NewRegistrationComponent { } }); } + + private setupSubmissionsAccessCheck() { + effect(() => { + const provider = this.provider(); + + if (provider && !provider.allowSubmissions) { + this.toastService.showError('registries.new.registryClosedForSubmissions'); + this.router.navigate(['/registries', provider.id]); + } + }); + } } 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 cc671860b..e8f7a46ba 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 @@ -13,11 +13,13 @@ }
- + @if (!isProviderLoading() && provider()!.allowSubmissions) { + + }
diff --git a/src/app/shared/mappers/registration-provider.mapper.ts b/src/app/shared/mappers/registration-provider.mapper.ts index 8b7fefd62..d97c2ebf8 100644 --- a/src/app/shared/mappers/registration-provider.mapper.ts +++ b/src/app/shared/mappers/registration-provider.mapper.ts @@ -34,6 +34,7 @@ export class RegistrationProviderMapper { : null, iri: response.links.iri, reviewsWorkflow: response.attributes.reviews_workflow, + allowSubmissions: response.attributes.allow_submissions, }; } } diff --git a/src/app/shared/models/provider/registry-provider.model.ts b/src/app/shared/models/provider/registry-provider.model.ts index fabd9d824..1c914acd9 100644 --- a/src/app/shared/models/provider/registry-provider.model.ts +++ b/src/app/shared/models/provider/registry-provider.model.ts @@ -10,4 +10,5 @@ export interface RegistryProviderDetails { brand: BrandModel | null; iri: string; reviewsWorkflow: string; + allowSubmissions: boolean; } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 768a18305..e31380f1c 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2511,7 +2511,8 @@ }, "selectProject": "Select your project", "createDraft": "Create draft", - "createdSuccessfully": "Draft created successfully" + "createdSuccessfully": "Draft created successfully", + "registryClosedForSubmissions": "This registry is closed for new submissions. Please start a new registration with a different registry." }, "deleteDraft": "Delete Draft", "confirmDeleteDraft": "Are you sure you want to delete this draft registration?", From 2372b4877aa0715f7a61dfd91676079e86ece473 Mon Sep 17 00:00:00 2001 From: Vlad0n20 <137097005+Vlad0n20@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:26:51 +0200 Subject: [PATCH 25/27] fix(ENG-10424): Fix view files in files section using VOL (#904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ticket: [ENG-10414] - Feature flag: n/a ## Purpose Fix view files in files section using VOL ## Summary of Changes Added { queryParamsHandling: 'preserve' } to two router.navigate() calls in files.component.ts — one in handleRootFolderChange() (provider switching) and one in the invalid-provider fallback effect --- .../files/pages/files/files.component.spec.ts | 140 +++++++++++++++++- .../files/pages/files/files.component.ts | 6 +- 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/src/app/features/files/pages/files/files.component.spec.ts b/src/app/features/files/pages/files/files.component.spec.ts index 656984423..36741cc1a 100644 --- a/src/app/features/files/pages/files/files.component.spec.ts +++ b/src/app/features/files/pages/files/files.component.spec.ts @@ -4,7 +4,7 @@ import { DialogService } from 'primeng/dynamicdialog'; import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { SENTRY_TOKEN } from '@core/provider/sentry.provider'; import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.component'; @@ -18,8 +18,10 @@ import { CustomConfirmationService } from '@osf/shared/services/custom-confirmat import { FilesService } from '@osf/shared/services/files.service'; import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; import { GoogleFilePickerComponent } from '@shared/components/google-file-picker/google-file-picker.component'; +import { FileLabelModel } from '@shared/models/files/file-label.model'; import { FilesSelectionActionsComponent } from '../../components'; +import { FileProvider } from '../../constants'; import { FilesSelectors } from '../../store'; import { FilesComponent } from './files.component'; @@ -29,6 +31,8 @@ import { getNodeFilesMappedData } from '@testing/data/files/node.data'; import { testNode } from '@testing/mocks/base-node.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; import { MockComponentWithSignal } from '@testing/providers/component-provider.mock'; +import { ActivatedRouteMock } from '@testing/providers/route-provider.mock'; +import { provideRouterMock, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('Component: Files', () => { @@ -188,4 +192,138 @@ describe('Component: Files', () => { expect(() => component.updateFilesList()).not.toThrow(); }); }); + + describe('handleRootFolderChange', () => { + it('should preserve view_only query param when switching storage providers', () => { + const router = TestBed.inject(Router); + const navigateSpy = jest.spyOn(router, 'navigate').mockResolvedValue(true); + + const selectedFolder: FileLabelModel = { + label: 'Dropbox', + folder: { provider: FileProvider.Dropbox } as any, + }; + + component.handleRootFolderChange(selectedFolder); + + expect(navigateSpy).toHaveBeenCalledWith([`/${component.resourceId()}/files`, FileProvider.Dropbox], { + queryParamsHandling: 'preserve', + }); + }); + }); + + describe('invalid provider fallback effect', () => { + let innerComponent: FilesComponent; + let innerFixture: ComponentFixture; + let routerMock: RouterMockType; + + beforeEach(async () => { + jest.clearAllMocks(); + routerMock = { + ...TestBed.inject(Router), + navigate: jest.fn().mockResolvedValue(true), + url: '/abc123/files/unknownprovider?view_only=testtoken', + } as RouterMockType; + + await TestBed.configureTestingModule({ + imports: [ + FilesComponent, + OSFTestingModule, + ...MockComponents( + FileUploadDialogComponent, + FormSelectComponent, + GoogleFilePickerComponent, + LoadingSpinnerComponent, + SearchInputComponent, + SubHeaderComponent, + ViewOnlyLinkMessageComponent, + GoogleFilePickerComponent, + FilesSelectionActionsComponent + ), + ], + providers: [ + FilesService, + MockProvider(CustomConfirmationService), + DialogService, + { + provide: SENTRY_TOKEN, + useValue: { + captureException: jest.fn(), + captureMessage: jest.fn(), + setUser: jest.fn(), + }, + }, + { + provide: ActivatedRoute, + useValue: ActivatedRouteMock.withParams({ fileProvider: 'unknownprovider' }).build(), + }, + provideRouterMock(routerMock), + provideMockStore({ + signals: [ + { + selector: CurrentResourceSelectors.getResourceDetails, + value: testNode, + }, + { + selector: FilesSelectors.getRootFolders, + value: getNodeFilesMappedData(), + }, + { + selector: FilesSelectors.getCurrentFolder, + value: getNodeFilesMappedData(0), + }, + { + selector: FilesSelectors.getConfiguredStorageAddons, + value: getConfiguredAddonsMappedData(), + }, + { + selector: FilesSelectors.getProvider, + value: 'osfstorage', + }, + { + selector: FilesSelectors.getStorageSupportedFeatures, + value: { + osfstorage: ['AddUpdateFiles', 'DownloadAsZip', 'DeleteFiles', 'CopyInto'], + }, + }, + ], + }), + ], + }) + .overrideComponent(FilesComponent, { + remove: { + imports: [FilesTreeComponent], + }, + add: { + imports: [ + MockComponentWithSignal('osf-files-tree', [ + 'files', + 'currentFolder', + 'isLoading', + 'viewOnly', + 'resourceId', + 'provider', + 'storage', + 'totalCount', + 'allowedMenuActions', + 'supportUpload', + 'selectedFiles', + 'scrollHeight', + ]), + ], + }, + }) + .compileComponents(); + + innerFixture = TestBed.createComponent(FilesComponent); + innerComponent = innerFixture.componentInstance; + innerFixture.detectChanges(); + }); + + it('should preserve view_only query param when redirecting to osfstorage for invalid provider', () => { + expect(routerMock.navigate).toHaveBeenCalledWith( + [`/${innerComponent.resourceId()}/files`, FileProvider.OsfStorage], + { queryParamsHandling: 'preserve' } + ); + }); + }); }); diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index f06b4a010..75b852eae 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -292,7 +292,9 @@ export class FilesComponent { const rootFoldersOption = rootFoldersOptions.find((option) => option.folder.provider === providerName); if (!rootFoldersOption) { - this.router.navigate([`/${this.resourceId()}/files`, FileProvider.OsfStorage]); + this.router.navigate([`/${this.resourceId()}/files`, FileProvider.OsfStorage], { + queryParamsHandling: 'preserve', + }); } else { this.currentRootFolder.set({ label: rootFoldersOption.label, @@ -688,6 +690,6 @@ export class FilesComponent { handleRootFolderChange(selectedFolder: FileLabelModel) { const provider = selectedFolder.folder?.provider; const resourceId = this.resourceId(); - this.router.navigate([`/${resourceId}/files`, provider]); + this.router.navigate([`/${resourceId}/files`, provider], { queryParamsHandling: 'preserve' }); } } From d226c7041ec9d566ac486db158021c18eb8770cc Mon Sep 17 00:00:00 2001 From: futa-ikeda <51409893+futa-ikeda@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:32:59 -0400 Subject: [PATCH 26/27] feat(registries): Add SCORE as registration provider (#905) - Ticket: [ENG-10385] - Feature flag: n/a ## Purpose - Add SCORE registration to registries discover page ## Summary of Changes - Add SCORE to list of registries services - Add SCORE logo --- .../constants/registry-services-icons.const.ts | 4 ++++ .../images/registries-services/SCORE_logo.png | Bin 0 -> 501426 bytes 2 files changed, 4 insertions(+) create mode 100644 src/assets/images/registries-services/SCORE_logo.png diff --git a/src/app/shared/constants/registry-services-icons.const.ts b/src/app/shared/constants/registry-services-icons.const.ts index 5b43f6813..fea2ac827 100644 --- a/src/app/shared/constants/registry-services-icons.const.ts +++ b/src/app/shared/constants/registry-services-icons.const.ts @@ -35,4 +35,8 @@ export const RegistryServiceIcons = [ src: 'assets/images/registries-services/GFS_Logo.png', id: 'gfs', }, + { + src: 'assets/images/registries-services/SCORE_logo.png', + id: 'score', + }, ]; diff --git a/src/assets/images/registries-services/SCORE_logo.png b/src/assets/images/registries-services/SCORE_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..17473f33065b3599ddd90509142a80bcbdc26490 GIT binary patch literal 501426 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 HogMz+gJ`C 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ΜTN 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 H6ZHQ8(p-#e literal 0 HcmV?d00001 From fe1fc61b2aeaf56e7b6fc6bb184a10fbfe7af72d Mon Sep 17 00:00:00 2001 From: nsemets Date: Fri, 13 Mar 2026 17:50:40 +0200 Subject: [PATCH 27/27] [ENG-10560] Make angular routing redirects have rel canonical links (#907) - Ticket: [ENG-10560] - Feature flag: n/a ## Summary of Changes 1. Added canonical url for guid pages. 2. Updated unit tests. --- angular.json | 17 ++ package.json | 1 + src/app/features/files/files.routes.ts | 9 +- .../file-detail/file-detail.component.ts | 33 +-- src/app/features/metadata/metadata.routes.ts | 1 + .../preprint-details.component.spec.ts | 27 +- .../preprint-details.component.ts | 40 +-- .../project/project.component.spec.ts | 275 ++++++++++++++---- src/app/features/project/project.component.ts | 112 ++++--- src/app/features/project/project.routes.ts | 19 +- .../registry/registry.component.spec.ts | 88 +++++- .../features/registry/registry.component.ts | 91 +++--- src/app/features/registry/registry.routes.ts | 16 +- .../shared/helpers/canonical-path.helper.ts | 76 +++++ .../models/meta-tags/head-tag-def.model.ts | 21 +- .../models/meta-tags/meta-tags-data.model.ts | 1 + .../meta-tags-builder.service.spec.ts | 179 ++++++++++++ .../services/meta-tags-builder.service.ts | 139 +++++++++ .../shared/services/meta-tags.service.spec.ts | 124 ++++++++ src/app/shared/services/meta-tags.service.ts | 175 +++++------ .../services/metadata-records.service.spec.ts | 52 ++++ .../services/metadata-records.service.ts | 13 +- .../meta-tags-builder.service.mock.ts | 16 + 23 files changed, 1197 insertions(+), 328 deletions(-) create mode 100644 src/app/shared/helpers/canonical-path.helper.ts create mode 100644 src/app/shared/services/meta-tags-builder.service.spec.ts create mode 100644 src/app/shared/services/meta-tags-builder.service.ts create mode 100644 src/app/shared/services/meta-tags.service.spec.ts create mode 100644 src/app/shared/services/metadata-records.service.spec.ts create mode 100644 src/testing/providers/meta-tags-builder.service.mock.ts diff --git a/angular.json b/angular.json index 45489de17..1457228db 100644 --- a/angular.json +++ b/angular.json @@ -117,6 +117,20 @@ "namedChunks": true }, "development": { + "outputMode": "static", + "server": false, + "ssr": false, + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ] + }, + "dev-ssr": { "optimization": false, "extractLicenses": false, "sourceMap": true, @@ -164,6 +178,9 @@ "development": { "buildTarget": "osf:build:development" }, + "dev-ssr": { + "buildTarget": "osf:build:dev-ssr" + }, "docker": { "buildTarget": "osf:build:docker" }, diff --git a/package.json b/package.json index 80657c032..fafb56652 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "ngxs:store": "ng generate @ngxs/store:store --name --path", "prepare": "husky", "start": "ng serve", + "start:ssr": "ng serve --configuration dev-ssr", "start:docker": "npm run check:config && ng serve --host 0.0.0.0 --port 4200 --poll 2000 --configuration development", "start:docker:local": "npm run check:config && ng serve --host 0.0.0.0 --port 4200 --poll 2000 --configuration docker", "test": "jest", diff --git a/src/app/features/files/files.routes.ts b/src/app/features/files/files.routes.ts index 5c93c2baf..8b4cb2b77 100644 --- a/src/app/features/files/files.routes.ts +++ b/src/app/features/files/files.routes.ts @@ -18,6 +18,7 @@ export const filesRoutes: Routes = [ { path: ':fileProvider', canMatch: [isFileProvider], + data: { canonicalPathTemplate: 'files/:fileProvider' }, loadComponent: () => import('@osf/features/files/pages/files/files.component').then((c) => c.FilesComponent), }, { @@ -27,18 +28,12 @@ export const filesRoutes: Routes = [ }, { path: ':fileGuid', + data: { canonicalPathTemplate: 'files/:fileGuid' }, loadComponent: () => { return import('@osf/features/files/pages/file-detail/file-detail.component').then( (c) => c.FileDetailComponent ); }, - children: [ - { - path: 'metadata', - loadChildren: () => import('@osf/features/metadata/metadata.routes').then((mod) => mod.metadataRoutes), - data: { resourceType: ResourceType.File }, - }, - ], }, ], }, diff --git a/src/app/features/files/pages/file-detail/file-detail.component.ts b/src/app/features/files/pages/file-detail/file-detail.component.ts index 9dc06ff04..a9c4ec849 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.ts @@ -1,6 +1,6 @@ import { createDispatchMap, select, Store } from '@ngxs/store'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Menu } from 'primeng/menu'; @@ -10,7 +10,6 @@ import { Tab, TabList, Tabs } from 'primeng/tabs'; import { switchMap } from 'rxjs'; import { Clipboard } from '@angular/cdk/clipboard'; -import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -43,10 +42,10 @@ import { MetadataTabsComponent } from '@osf/shared/components/metadata-tabs/meta import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { MetadataResourceEnum } from '@osf/shared/enums/metadata-resource.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { pathJoin } from '@osf/shared/helpers/path-join.helper'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; +import { MetaTagsBuilderService } from '@osf/shared/services/meta-tags-builder.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { FileDetailsModel } from '@shared/models/files/file.model'; @@ -92,7 +91,6 @@ import { templateUrl: './file-detail.component.html', styleUrl: './file-detail.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [DatePipe], }) export class FileDetailComponent { @HostBinding('class') classes = 'flex flex-column flex-1 w-full h-full'; @@ -106,16 +104,13 @@ export class FileDetailComponent { readonly customConfirmationService = inject(CustomConfirmationService); private readonly metaTags = inject(MetaTagsService); - private readonly datePipe = inject(DatePipe); + private readonly metaTagsBuilder = inject(MetaTagsBuilderService); private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); - private readonly translateService = inject(TranslateService); private readonly environment = inject(ENVIRONMENT); private readonly clipboard = inject(Clipboard); readonly dataciteService = inject(DataciteService); - private readonly webUrl = this.environment.webUrl; - private readonly actions = createDispatchMap({ getFile: GetFile, getFileRevisions: GetFileRevisions, @@ -204,24 +199,14 @@ export class FileDetailComponent { } const file = this.file(); + if (!file) return null; - return { - osfGuid: file.guid, - title: this.fileCustomMetadata()?.title || file.name, - type: this.fileCustomMetadata()?.resourceTypeGeneral, - description: - this.fileCustomMetadata()?.description ?? this.translateService.instant('files.metaTagDescriptionPlaceholder'), - url: pathJoin(this.webUrl, this.fileGuid), - publishedDate: this.datePipe.transform(file.dateCreated, 'yyyy-MM-dd'), - modifiedDate: this.datePipe.transform(file.dateModified, 'yyyy-MM-dd'), - language: this.fileCustomMetadata()?.language, - contributors: this.resourceContributors()?.map((contributor) => ({ - fullName: contributor.fullName, - givenName: contributor.givenName, - familyName: contributor.familyName, - })), - }; + return this.metaTagsBuilder.buildFileMetaTagsData({ + file, + fileMetadata: this.fileCustomMetadata(), + contributors: this.resourceContributors() ?? [], + }); }); constructor() { diff --git a/src/app/features/metadata/metadata.routes.ts b/src/app/features/metadata/metadata.routes.ts index cc8c4097e..5c8e80eac 100644 --- a/src/app/features/metadata/metadata.routes.ts +++ b/src/app/features/metadata/metadata.routes.ts @@ -14,6 +14,7 @@ export const metadataRoutes: Routes = [ }, { path: ':recordId', + data: { canonicalPathTemplate: 'metadata/:recordId' }, component: MetadataComponent, }, ]; 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 d9cc1d1a1..6823577f4 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 @@ -13,9 +13,11 @@ import { ActivatedRoute, Router } from '@angular/router'; import { HelpScoutService } from '@core/services/help-scout.service'; import { PrerenderReadyService } from '@core/services/prerender-ready.service'; import { ClearCurrentProvider } from '@core/store/provider'; +import { MetaTagsData } from '@osf/shared/models/meta-tags/meta-tags-data.model'; 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 { MetaTagsBuilderService } from '@osf/shared/services/meta-tags-builder.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; @@ -55,6 +57,7 @@ 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 { MetaTagsBuilderServiceMockFactory } from '@testing/providers/meta-tags-builder.service.mock'; import { PrerenderReadyServiceMockFactory } from '@testing/providers/prerender-ready.service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; @@ -70,6 +73,7 @@ describe('PreprintDetailsComponent', () => { let prerenderReadyServiceMock: jest.Mocked; let dataciteServiceMock: ReturnType; let metaTagsServiceMock: ReturnType; + let metaTagsBuilderServiceMock: ReturnType; let customDialogServiceMock: ReturnType; let toastService: ToastServiceMockType; @@ -122,6 +126,13 @@ describe('PreprintDetailsComponent', () => { prerenderReadyServiceMock = PrerenderReadyServiceMockFactory(); dataciteServiceMock = DataciteMockFactory(); metaTagsServiceMock = MetaTagsServiceMockFactory(); + metaTagsBuilderServiceMock = MetaTagsBuilderServiceMockFactory(); + metaTagsBuilderServiceMock.buildPreprintMetaTagsData.mockImplementation( + ({ providerId, preprint }) => + ({ + canonicalUrl: `http://localhost:4200/preprints/${providerId}/${preprint?.id}`, + }) as MetaTagsData + ); toastService = ToastServiceMock.simple(); customDialogServiceMock = overrides?.dialogReturnsCloseValue === false @@ -167,6 +178,7 @@ describe('PreprintDetailsComponent', () => { MockProvider(PrerenderReadyService, prerenderReadyServiceMock), MockProvider(DataciteService, dataciteServiceMock), MockProvider(MetaTagsService, metaTagsServiceMock), + MockProvider(MetaTagsBuilderService, metaTagsBuilderServiceMock), MockProvider(CustomDialogService, customDialogServiceMock), provideMockStore({ signals }), ], @@ -199,7 +211,19 @@ describe('PreprintDetailsComponent', () => { it('should update meta tags when preprint and contributors are loaded', () => { setup(); - expect(metaTagsServiceMock.updateMetaTags).toHaveBeenCalled(); + expect(metaTagsBuilderServiceMock.buildPreprintMetaTagsData).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: 'osf', + preprint: expect.objectContaining({ id: 'preprint-1' }), + }) + ); + + expect(metaTagsServiceMock.updateMetaTags).toHaveBeenCalledWith( + expect.objectContaining({ + canonicalUrl: 'http://localhost:4200/preprints/osf/preprint-1', + }), + expect.anything() + ); }); it('should not fetch moderation actions when not moderator and no permissions', () => { @@ -532,6 +556,7 @@ describe('PreprintDetailsComponent SSR', () => { MockProvider(Router, routerMock), MockProvider(CustomDialogService, CustomDialogServiceMockBuilder.create().withDefaultOpen().build()), MockProvider(DataciteService, DataciteMockFactory()), + MockProvider(MetaTagsBuilderService, MetaTagsBuilderServiceMockFactory()), MockProvider(MetaTagsService, MetaTagsServiceMockFactory()), MockProvider(PrerenderReadyService, PrerenderReadyServiceMockFactory()), MockProvider(HelpScoutService, helpScoutServiceMock), 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 a904cff99..df896e0da 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 @@ -7,7 +7,7 @@ import { Skeleton } from 'primeng/skeleton'; import { catchError, EMPTY, filter, map } from 'rxjs'; -import { DatePipe, isPlatformBrowser } from '@angular/common'; +import { isPlatformBrowser } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, @@ -30,10 +30,10 @@ import { PrerenderReadyService } from '@core/services/prerender-ready.service'; 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 { 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 { MetaTagsBuilderService } from '@osf/shared/services/meta-tags-builder.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; @@ -82,7 +82,6 @@ import { CreateNewVersion, PreprintStepperSelectors } from '../../store/preprint ], templateUrl: './preprint-details.component.html', styleUrl: './preprint-details.component.scss', - providers: [DatePipe], changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintDetailsComponent implements OnInit, OnDestroy { @@ -97,14 +96,13 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { private readonly customDialogService = inject(CustomDialogService); private readonly translateService = inject(TranslateService); private readonly metaTags = inject(MetaTagsService); - private readonly datePipe = inject(DatePipe); + private readonly metaTagsBuilder = inject(MetaTagsBuilderService); private readonly dataciteService = inject(DataciteService); private readonly prerenderReady = inject(PrerenderReadyService); - private readonly platformId = inject(PLATFORM_ID); private readonly environment = inject(ENVIRONMENT); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); - private readonly isBrowser = isPlatformBrowser(this.platformId); - + readonly providerId = toSignal(this.route.params.pipe(map((params) => params['providerId']))); private readonly preprintId = toSignal(this.route.params.pipe(map((params) => params['id']))); private readonly actions = createDispatchMap({ @@ -118,7 +116,6 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { clearCurrentProvider: ClearCurrentProvider, }); - 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); @@ -410,26 +407,13 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { } private setMetaTags() { - this.metaTags.updateMetaTags( - { - osfGuid: this.preprint()?.id, - title: this.preprint()?.title, - description: this.preprint()?.description, - publishedDate: this.datePipe.transform(this.preprint()?.datePublished, 'yyyy-MM-dd'), - modifiedDate: this.datePipe.transform(this.preprint()?.dateModified, 'yyyy-MM-dd'), - url: pathJoin(this.environment.webUrl, this.preprint()?.id ?? ''), - doi: this.preprint()?.doi, - keywords: this.preprint()?.tags, - siteName: 'OSF', - license: this.preprint()?.embeddedLicense?.name, - contributors: this.contributors().map((contributor) => ({ - fullName: contributor.fullName, - givenName: contributor.givenName, - familyName: contributor.familyName, - })), - }, - this.destroyRef - ); + const metaTags = this.metaTagsBuilder.buildPreprintMetaTagsData({ + providerId: this.providerId(), + preprint: this.preprint(), + contributors: this.contributors(), + }); + + this.metaTags.updateMetaTags(metaTags, this.destroyRef); } private checkAndSetVersionToTheUrl() { diff --git a/src/app/features/project/project.component.spec.ts b/src/app/features/project/project.component.spec.ts index 72d6774ba..88b558a8c 100644 --- a/src/app/features/project/project.component.spec.ts +++ b/src/app/features/project/project.component.spec.ts @@ -1,83 +1,248 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { HelpScoutService } from '@core/services/help-scout.service'; import { PrerenderReadyService } from '@core/services/prerender-ready.service'; +import { ProjectOverviewModel } from '@osf/features/project/overview/models'; +import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; +import { MetaTagsData } from '@osf/shared/models/meta-tags/meta-tags-data.model'; +import { AnalyticsService } from '@osf/shared/services/analytics.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; +import { MetaTagsBuilderService } from '@osf/shared/services/meta-tags-builder.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; -import { ProjectOverviewSelectors } from './overview/store'; +import { GetProjectById, GetProjectIdentifiers, GetProjectLicense, ProjectOverviewSelectors } from './overview/store'; import { ProjectComponent } from './project.component'; import { DataciteMockFactory } from '@testing/mocks/datacite.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { AnalyticsServiceMockFactory } from '@testing/providers/analytics.service.mock'; import { HelpScoutServiceMockFactory } from '@testing/providers/help-scout.service.mock'; import { MetaTagsServiceMockFactory } from '@testing/providers/meta-tags.service.mock'; +import { MetaTagsBuilderServiceMockFactory } from '@testing/providers/meta-tags-builder.service.mock'; import { PrerenderReadyServiceMockFactory } from '@testing/providers/prerender-ready.service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; -describe('Component: Project', () => { - let component: ProjectComponent; - let fixture: ComponentFixture; - let helpScoutService: ReturnType; - let metaTagsService: ReturnType; - let dataciteService: ReturnType; - let prerenderReadyService: ReturnType; - let mockActivatedRoute: ReturnType; - - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'project-1' }).build(); - - helpScoutService = HelpScoutServiceMockFactory(); - metaTagsService = MetaTagsServiceMockFactory(); - dataciteService = DataciteMockFactory(); - prerenderReadyService = PrerenderReadyServiceMockFactory(); - - await TestBed.configureTestingModule({ - imports: [ProjectComponent, OSFTestingModule], - providers: [ - { provide: HelpScoutService, useValue: helpScoutService }, - { provide: MetaTagsService, useValue: metaTagsService }, - { provide: DataciteService, useValue: dataciteService }, - { provide: PrerenderReadyService, useValue: prerenderReadyService }, - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - provideMockStore({ - signals: [ - { selector: ProjectOverviewSelectors.getProject, value: null }, - { selector: ProjectOverviewSelectors.getProjectLoading, value: false }, - { selector: ProjectOverviewSelectors.getIdentifiers, value: [] }, - { selector: ProjectOverviewSelectors.getLicense, value: null }, - { selector: ProjectOverviewSelectors.isLicenseLoading, value: false }, - { selector: ContributorsSelectors.getBibliographicContributors, value: [] }, - { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, - { selector: CurrentResourceSelectors.getCurrentResource, value: null }, - ], - }), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(ProjectComponent); - component = fixture.componentInstance; - fixture.detectChanges(); +interface SetupOverrides extends BaseSetupOverrides { + projectId?: string; + selectorOverrides?: SignalOverride[]; + childCanonicalPath?: string; + childCanonicalPathTemplate?: string; + childParams?: Record; +} + +function setup(overrides: SetupOverrides = {}) { + const projectId = overrides.projectId ?? 'project-1'; + const helpScoutService = HelpScoutServiceMockFactory(); + const analyticsService = AnalyticsServiceMockFactory(); + const metaTagsService = MetaTagsServiceMockFactory(); + const metaTagsBuilderService = MetaTagsBuilderServiceMockFactory(); + const dataciteService = DataciteMockFactory(); + const prerenderReadyService = PrerenderReadyServiceMockFactory(); + const routerBuilder = RouterMockBuilder.create(); + const routerMock = routerBuilder.build(); + + const routeBuilder = ActivatedRouteMockBuilder.create().withParams(overrides.routeParams ?? { id: projectId }); + + if (overrides.hasParent === false) { + routeBuilder.withNoParent(); + } + + if (overrides.childCanonicalPath || overrides.childCanonicalPathTemplate || overrides.childParams) { + routeBuilder.withFirstChild((builder) => { + if (overrides.childCanonicalPath) { + builder.withData({ canonicalPath: overrides.childCanonicalPath }); + } + if (overrides.childCanonicalPathTemplate) { + builder.withData({ canonicalPathTemplate: overrides.childCanonicalPathTemplate }); + } + if (overrides.childParams) { + builder.withParams(overrides.childParams); + } + }); + } + + const mockActivatedRoute = routeBuilder.build(); + + metaTagsBuilderService.buildProjectMetaTagsData.mockImplementation( + ({ project, canonicalPath }): MetaTagsData => + ({ + osfGuid: project.id, + canonicalUrl: `http://localhost:4200/${project.id}/${canonicalPath}`, + }) as MetaTagsData + ); + + const defaultSignals: SignalOverride[] = [ + { selector: ProjectOverviewSelectors.getProject, value: null }, + { selector: ProjectOverviewSelectors.getProjectLoading, value: false }, + { selector: ProjectOverviewSelectors.getIdentifiers, value: [] }, + { selector: ProjectOverviewSelectors.getLicense, value: { name: 'MIT' } }, + { selector: ProjectOverviewSelectors.isLicenseLoading, value: false }, + { selector: ContributorsSelectors.getBibliographicContributors, value: [] }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, + { selector: CurrentResourceSelectors.getCurrentResource, value: null }, + ]; + + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [ProjectComponent], + providers: [ + provideOSFCore(), + MockProvider(HelpScoutService, helpScoutService), + MockProvider(AnalyticsService, analyticsService), + MockProvider(MetaTagsService, metaTagsService), + MockProvider(MetaTagsBuilderService, metaTagsBuilderService), + MockProvider(DataciteService, dataciteService), + MockProvider(PrerenderReadyService, prerenderReadyService), + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, routerMock), + provideMockStore({ signals }), + ], }); - it('should call the helpScoutService', () => { + const store = TestBed.inject(Store); + const fixture = TestBed.createComponent(ProjectComponent); + const component = fixture.componentInstance; + fixture.detectChanges(); + + return { + component, + fixture, + store, + routerBuilder, + routerMock, + helpScoutService, + analyticsService, + metaTagsService, + metaTagsBuilderService, + dataciteService, + prerenderReadyService, + }; +} + +describe('Component: Project', () => { + it('should set helpScout resource type and prerender not ready on init', () => { + const { helpScoutService, prerenderReadyService } = setup(); + expect(helpScoutService.setResourceType).toHaveBeenCalledWith('project'); + expect(prerenderReadyService.setNotReady).toHaveBeenCalled(); }); - it('should call unsetResourceType on destroy', () => { - component.ngOnDestroy(); - expect(helpScoutService.unsetResourceType).toHaveBeenCalled(); + it('should dispatch init actions when project id is available', () => { + const { store } = setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new GetProjectById('project-1')); + expect(store.dispatch).toHaveBeenCalledWith(new GetProjectIdentifiers('project-1')); }); - it('should call prerenderReady.setNotReady in constructor', () => { - expect(prerenderReadyService.setNotReady).toHaveBeenCalled(); + it('should dispatch get license when project has licenseId', () => { + const project = { ...MOCK_PROJECT_OVERVIEW, licenseId: 'license-1' } as ProjectOverviewModel; + const { store } = setup({ + selectorOverrides: [{ selector: ProjectOverviewSelectors.getProject, value: project }], + }); + + expect(store.dispatch).toHaveBeenCalledWith(new GetProjectLicense('license-1')); }); - it('should call dataciteService.logIdentifiableView', () => { + it('should call datacite tracking on init', () => { + const { dataciteService } = setup(); + expect(dataciteService.logIdentifiableView).toHaveBeenCalled(); }); + + it('should map identifiers to null when identifiers are empty', () => { + const { dataciteService } = setup(); + const identifiers$ = (dataciteService.logIdentifiableView as jest.Mock).mock.calls[0][0]; + let emitted: unknown; + + identifiers$.subscribe((value: unknown) => { + emitted = value; + }); + + expect(emitted).toBeNull(); + }); + + it('should map identifiers to payload when identifiers exist', () => { + const identifiers = [ + { + id: 'identifier-1', + type: 'identifiers', + category: 'doi', + value: '10.1234/osf.test', + }, + ] as IdentifierModel[]; + const { dataciteService } = setup({ + selectorOverrides: [{ selector: ProjectOverviewSelectors.getIdentifiers, value: identifiers }], + }); + const identifiers$ = (dataciteService.logIdentifiableView as jest.Mock).mock.calls[0][0]; + let emitted: unknown; + + identifiers$.subscribe((value: unknown) => { + emitted = value; + }); + + expect(emitted).toEqual({ identifiers }); + }); + + it('should build and update meta tags with canonical path from active subroute', () => { + const { metaTagsBuilderService, metaTagsService } = setup({ + selectorOverrides: [{ selector: ProjectOverviewSelectors.getProject, value: MOCK_PROJECT_OVERVIEW }], + childCanonicalPath: 'files/osfstorage', + }); + + expect(metaTagsBuilderService.buildProjectMetaTagsData).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.objectContaining({ id: 'project-1' }), + canonicalPath: 'files/osfstorage', + }) + ); + expect(metaTagsService.updateMetaTags).toHaveBeenCalledWith( + expect.objectContaining({ + canonicalUrl: 'http://localhost:4200/project-1/files/osfstorage', + }), + expect.anything() + ); + expect(metaTagsService.updateMetaTags).toHaveBeenCalledTimes(1); + }); + + it('should not build or update meta tags when current project is null', () => { + const { metaTagsBuilderService, metaTagsService } = setup(); + + expect(metaTagsBuilderService.buildProjectMetaTagsData).not.toHaveBeenCalled(); + expect(metaTagsService.updateMetaTags).not.toHaveBeenCalled(); + }); + + it('should send analytics on NavigationEnd', () => { + const { routerBuilder, analyticsService } = setup(); + + routerBuilder.emit(new NavigationEnd(1, '/project-1', '/project-1/overview')); + + expect(analyticsService.sendCountedUsageForRegistrationAndProjects).toHaveBeenCalledWith( + '/project-1/overview', + null + ); + }); + + it('should call unsetResourceType on destroy', () => { + const { component, helpScoutService } = setup(); + + component.ngOnDestroy(); + + expect(helpScoutService.unsetResourceType).toHaveBeenCalled(); + }); }); diff --git a/src/app/features/project/project.component.ts b/src/app/features/project/project.component.ts index 0ec8ced6b..bbcec1410 100644 --- a/src/app/features/project/project.component.ts +++ b/src/app/features/project/project.component.ts @@ -2,7 +2,6 @@ import { createDispatchMap, select } from '@ngxs/store'; import { filter, map } from 'rxjs'; -import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -20,8 +19,13 @@ import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/ro import { HelpScoutService } from '@core/services/help-scout.service'; import { PrerenderReadyService } from '@core/services/prerender-ready.service'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { + getDeepestCanonicalPathTemplateFromSnapshot, + resolveCanonicalPathFromSnapshot, +} from '@osf/shared/helpers/canonical-path.helper'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; +import { MetaTagsBuilderService } from '@osf/shared/services/meta-tags-builder.service'; import { ContributorsSelectors, GetBibliographicContributors } from '@osf/shared/stores/contributors'; import { AnalyticsService } from '@shared/services/analytics.service'; import { CurrentResourceSelectors } from '@shared/stores/current-resource'; @@ -34,26 +38,21 @@ import { GetProjectById, GetProjectIdentifiers, GetProjectLicense, ProjectOvervi templateUrl: './project.component.html', styleUrl: './project.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [DatePipe], }) export class ProjectComponent implements OnDestroy { @HostBinding('class') classes = 'flex flex-1 flex-column w-full'; private readonly helpScoutService = inject(HelpScoutService); private readonly metaTags = inject(MetaTagsService); + private readonly metaTagsBuilder = inject(MetaTagsBuilderService); private readonly dataciteService = inject(DataciteService); private readonly destroyRef = inject(DestroyRef); private readonly route = inject(ActivatedRoute); - private readonly datePipe = inject(DatePipe); private readonly prerenderReady = inject(PrerenderReadyService); private readonly router = inject(Router); private readonly analyticsService = inject(AnalyticsService); - private readonly currentResource = select(CurrentResourceSelectors.getCurrentResource); - - readonly identifiersForDatacite$ = toObservable(select(ProjectOverviewSelectors.getIdentifiers)).pipe( - map((identifiers) => (identifiers?.length ? { identifiers } : null)) - ); + readonly currentResource = select(CurrentResourceSelectors.getCurrentResource); readonly currentProject = select(ProjectOverviewSelectors.getProject); readonly isProjectLoading = select(ProjectOverviewSelectors.getProjectLoading); readonly bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); @@ -61,8 +60,21 @@ export class ProjectComponent implements OnDestroy { readonly license = select(ProjectOverviewSelectors.getLicense); readonly isLicenseLoading = select(ProjectOverviewSelectors.isLicenseLoading); - private readonly lastMetaTagsProjectId = signal(null); + readonly identifiersForDatacite$ = toObservable(select(ProjectOverviewSelectors.getIdentifiers)).pipe( + map((identifiers) => (identifiers?.length ? { identifiers } : null)) + ); + + private readonly actions = createDispatchMap({ + getProject: GetProjectById, + getLicense: GetProjectLicense, + getIdentifiers: GetProjectIdentifiers, + getBibliographicContributors: GetBibliographicContributors, + }); + private readonly projectId = toSignal(this.route.params.pipe(map((params) => params['id']))); + private readonly canonicalPath = signal(this.getCanonicalPathFromSnapshot()); + private readonly isFileDetailRoute = signal(this.isFileDetailRouteFromSnapshot()); + private readonly lastMetaTagsKey = signal(null); private readonly allDataLoaded = computed( () => @@ -72,13 +84,6 @@ export class ProjectComponent implements OnDestroy { !!this.currentProject() ); - private readonly actions = createDispatchMap({ - getProject: GetProjectById, - getLicense: GetProjectLicense, - getIdentifiers: GetProjectIdentifiers, - getBibliographicContributors: GetBibliographicContributors, - }); - constructor() { this.prerenderReady.setNotReady(); this.helpScoutService.setResourceType('project'); @@ -94,22 +99,35 @@ export class ProjectComponent implements OnDestroy { }); effect(() => { - const project = this.currentProject(); + const licenseId = this.currentProject()?.licenseId; - if (project?.licenseId) { - this.actions.getLicense(project.licenseId); + if (licenseId) { + this.actions.getLicense(licenseId); } }); effect(() => { - if (this.allDataLoaded()) { - const currentProjectId = this.projectId(); - const lastSetProjectId = this.lastMetaTagsProjectId(); + if (!this.allDataLoaded()) { + this.lastMetaTagsKey.set(null); + return; + } + + const currentProjectId = this.currentProject()?.id; + const currentCanonicalPath = this.canonicalPath(); - if (currentProjectId && currentProjectId !== lastSetProjectId) { - this.setMetaTags(); - } + if (!currentProjectId || !currentCanonicalPath || this.isFileDetailRoute()) { + this.lastMetaTagsKey.set(null); + return; } + + const metaTagsKey = `${currentProjectId}:${currentCanonicalPath}`; + + if (this.lastMetaTagsKey() === metaTagsKey) { + return; + } + + this.lastMetaTagsKey.set(metaTagsKey); + this.setMetaTags(); }); this.dataciteService @@ -123,6 +141,8 @@ export class ProjectComponent implements OnDestroy { takeUntilDestroyed(this.destroyRef) ) .subscribe((event: NavigationEnd) => { + this.canonicalPath.set(this.getCanonicalPathFromSnapshot()); + this.isFileDetailRoute.set(this.isFileDetailRouteFromSnapshot()); this.analyticsService.sendCountedUsageForRegistrationAndProjects( event.urlAfterRedirects, this.currentResource() @@ -135,29 +155,29 @@ export class ProjectComponent implements OnDestroy { } private setMetaTags(): void { - const project = this.currentProject(); - if (!project) return; - - const keywords = [...(project.tags || []), ...(project.category ? [project.category] : [])]; - - const metaTagsData = { - osfGuid: project.id, - title: project.title, - description: project.description, - url: project.links?.iri, - license: this.license.name, - publishedDate: this.datePipe.transform(project.dateCreated, 'yyyy-MM-dd'), - modifiedDate: this.datePipe.transform(project.dateModified, 'yyyy-MM-dd'), - keywords, - contributors: this.bibliographicContributors().map((contributor) => ({ - fullName: contributor.fullName, - givenName: contributor.givenName, - familyName: contributor.familyName, - })), - }; + const project = this.currentProject()!; + + const metaTagsData = this.metaTagsBuilder.buildProjectMetaTagsData({ + project, + canonicalPath: this.canonicalPath(), + contributors: this.bibliographicContributors(), + licenseName: this.license.name, + }); this.metaTags.updateMetaTags(metaTagsData, this.destroyRef); + } + + private getCanonicalPathFromSnapshot(): string { + return resolveCanonicalPathFromSnapshot(this.route.snapshot, { + fallbackPath: 'overview', + paramDefaults: { + fileProvider: 'osfstorage', + recordId: 'osf', + }, + }); + } - this.lastMetaTagsProjectId.set(project.id); + private isFileDetailRouteFromSnapshot(): boolean { + return getDeepestCanonicalPathTemplateFromSnapshot(this.route.snapshot) === 'files/:fileGuid'; } } diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts index 8c2861788..a4f4b153d 100644 --- a/src/app/features/project/project.routes.ts +++ b/src/app/features/project/project.routes.ts @@ -36,6 +36,7 @@ export const projectRoutes: Routes = [ path: 'overview', loadComponent: () => import('../project/overview/project-overview.component').then((mod) => mod.ProjectOverviewComponent), + data: { canonicalPath: 'overview' }, providers: [ provideStates([ NodeLinksState, @@ -52,13 +53,13 @@ export const projectRoutes: Routes = [ path: 'metadata', loadChildren: () => import('@osf/features/metadata/metadata.routes').then((mod) => mod.metadataRoutes), providers: [provideStates([SubjectsState, CollectionsState])], - data: { resourceType: ResourceType.Project }, + data: { resourceType: ResourceType.Project, canonicalPath: 'metadata/osf' }, canActivate: [viewOnlyGuard], }, { path: 'files', loadChildren: () => import('@osf/features/files/files.routes').then((mod) => mod.filesRoutes), - data: { resourceType: ResourceType.Project }, + data: { resourceType: ResourceType.Project, canonicalPath: 'files/osfstorage' }, }, { path: 'registrations', @@ -66,29 +67,31 @@ export const projectRoutes: Routes = [ providers: [provideStates([RegistrationsState])], loadComponent: () => import('../project/registrations/registrations.component').then((mod) => mod.RegistrationsComponent), + data: { canonicalPath: 'registrations' }, }, { path: 'settings', canActivate: [viewOnlyGuard], loadComponent: () => import('../project/settings/settings.component').then((mod) => mod.SettingsComponent), + data: { canonicalPath: 'settings' }, providers: [provideStates([SettingsState, ViewOnlyLinkState])], }, { path: 'contributors', canActivate: [viewOnlyGuard], loadComponent: () => import('../contributors/contributors.component').then((mod) => mod.ContributorsComponent), - data: { resourceType: ResourceType.Project }, + data: { resourceType: ResourceType.Project, canonicalPath: 'contributors' }, providers: [provideStates([ViewOnlyLinkState])], }, { path: 'analytics', loadComponent: () => import('../analytics/analytics.component').then((mod) => mod.AnalyticsComponent), - data: { resourceType: ResourceType.Project }, + data: { resourceType: ResourceType.Project, canonicalPath: 'analytics' }, providers: [provideStates([AnalyticsState])], }, { path: 'analytics/linked-projects', - data: { resourceType: ResourceType.Project }, + data: { resourceType: ResourceType.Project, canonicalPath: 'analytics/linked-projects' }, loadComponent: () => import('../analytics/components/view-linked-projects/view-linked-projects.component').then( (mod) => mod.ViewLinkedProjectsComponent @@ -97,7 +100,7 @@ export const projectRoutes: Routes = [ }, { path: 'analytics/duplicates', - data: { resourceType: ResourceType.Project }, + data: { resourceType: ResourceType.Project, canonicalPath: 'analytics/duplicates' }, loadComponent: () => import('../analytics/components/view-duplicates/view-duplicates.component').then( (mod) => mod.ViewDuplicatesComponent @@ -108,20 +111,24 @@ export const projectRoutes: Routes = [ path: 'wiki/:wikiName', loadComponent: () => import('../project/wiki/legacy-wiki-redirect.component').then((m) => m.LegacyWikiRedirectComponent), + data: { canonicalPath: 'wiki' }, }, { path: 'wiki', loadComponent: () => import('../project/wiki/wiki.component').then((mod) => mod.WikiComponent), + data: { canonicalPath: 'wiki' }, }, { path: 'addons', canActivate: [viewOnlyGuard], + data: { canonicalPath: 'addons' }, loadChildren: () => import('@osf/features/project/project-addons/project-addons.routes').then((mod) => mod.projectAddonsRoutes), }, { path: 'links', canActivate: [viewOnlyGuard], + data: { canonicalPath: 'links' }, loadComponent: () => import('../project/linked-services/linked-services.component').then((mod) => mod.LinkedServicesComponent), }, diff --git a/src/app/features/registry/registry.component.spec.ts b/src/app/features/registry/registry.component.spec.ts index 5c59c5f26..2c8ab38a9 100644 --- a/src/app/features/registry/registry.component.spec.ts +++ b/src/app/features/registry/registry.component.spec.ts @@ -9,9 +9,12 @@ import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { HelpScoutService } from '@core/services/help-scout.service'; import { PrerenderReadyService } from '@core/services/prerender-ready.service'; import { ClearCurrentProvider } from '@core/store/provider'; +import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; +import { MetaTagsData } from '@osf/shared/models/meta-tags/meta-tags-data.model'; import { AnalyticsService } from '@osf/shared/services/analytics.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; +import { MetaTagsBuilderService } from '@osf/shared/services/meta-tags-builder.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; import { CurrentResourceSelectors } from '@shared/stores/current-resource'; @@ -25,6 +28,7 @@ import { provideOSFCore } from '@testing/osf.testing.provider'; import { AnalyticsServiceMockFactory } from '@testing/providers/analytics.service.mock'; import { HelpScoutServiceMockFactory } from '@testing/providers/help-scout.service.mock'; import { MetaTagsServiceMockFactory } from '@testing/providers/meta-tags.service.mock'; +import { MetaTagsBuilderServiceMockFactory } from '@testing/providers/meta-tags-builder.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'; @@ -33,35 +37,56 @@ import { provideMockStore } from '@testing/providers/store-provider.mock'; interface SetupOverrides { registryId?: string; registry?: RegistrationOverviewModel | null; + identifiers?: IdentifierModel[]; + canonicalPath?: string; platform?: string; } function setup(overrides: SetupOverrides = {}) { const registryId = overrides.registryId ?? 'registry-1'; const registry = overrides.registry ?? null; + const identifiers = overrides.identifiers ?? []; + const canonicalPath = overrides.canonicalPath; const helpScoutService = HelpScoutServiceMockFactory(); const metaTagsService = MetaTagsServiceMockFactory(); + const metaTagsBuilderService = MetaTagsBuilderServiceMockFactory(); const dataciteService = DataciteMockFactory(); const prerenderReadyService = PrerenderReadyServiceMockFactory(); const analyticsService = AnalyticsServiceMockFactory(); const routerBuilder = RouterMockBuilder.create(); const mockRouter = routerBuilder.build(); + const routeBuilder = ActivatedRouteMockBuilder.create().withParams({ id: registryId }); + if (canonicalPath) { + routeBuilder.withFirstChild((child) => child.withData({ canonicalPath })); + } + metaTagsBuilderService.buildRegistryMetaTagsData.mockImplementation( + ({ registry: currentRegistry, canonicalPath }) => + ({ + osfGuid: currentRegistry.id, + title: currentRegistry.title, + description: currentRegistry.description, + url: `http://localhost:4200/${currentRegistry.id}`, + canonicalUrl: `http://localhost:4200/${currentRegistry.id}/${canonicalPath}`, + siteName: 'OSF', + }) as MetaTagsData + ); const providers: Provider[] = [ provideOSFCore(), MockProvider(HelpScoutService, helpScoutService), MockProvider(MetaTagsService, metaTagsService), + MockProvider(MetaTagsBuilderService, metaTagsBuilderService), MockProvider(DataciteService, dataciteService), MockProvider(PrerenderReadyService, prerenderReadyService), MockProvider(AnalyticsService, analyticsService), - MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().withParams({ id: registryId }).build()), + MockProvider(ActivatedRoute, routeBuilder.build()), MockProvider(Router, mockRouter), provideMockStore({ signals: [ { selector: RegistrySelectors.getRegistry, value: registry }, { selector: RegistrySelectors.isRegistryLoading, value: false }, - { selector: RegistrySelectors.getIdentifiers, value: [] }, + { selector: RegistrySelectors.getIdentifiers, value: identifiers }, { selector: RegistrySelectors.getLicense, value: { name: 'MIT' } }, { selector: RegistrySelectors.isLicenseLoading, value: false }, { selector: ContributorsSelectors.getBibliographicContributors, value: [] }, @@ -91,6 +116,7 @@ function setup(overrides: SetupOverrides = {}) { routerBuilder, helpScoutService, metaTagsService, + metaTagsBuilderService, dataciteService, prerenderReadyService, analyticsService, @@ -111,6 +137,38 @@ describe('RegistryComponent', () => { expect(dataciteService.logIdentifiableView).toHaveBeenCalled(); }); + it('should map identifiers to null when identifiers are empty', () => { + const { dataciteService } = setup({ identifiers: [] }); + const identifiers$ = (dataciteService.logIdentifiableView as jest.Mock).mock.calls[0][0]; + let emitted: unknown; + + identifiers$.subscribe((value: unknown) => { + emitted = value; + }); + + expect(emitted).toBeNull(); + }); + + it('should map identifiers to payload when identifiers exist', () => { + const identifiers: IdentifierModel[] = [ + { + id: 'identifier-1', + type: 'identifiers', + category: 'doi', + value: '10.1234/osf.test', + }, + ]; + const { dataciteService } = setup({ identifiers }); + const identifiers$ = (dataciteService.logIdentifiableView as jest.Mock).mock.calls[0][0]; + let emitted: unknown; + + identifiers$.subscribe((value: unknown) => { + emitted = value; + }); + + expect(emitted).toEqual({ identifiers }); + }); + it('should dispatch init actions when registryId is available', () => { const { store } = setup(); @@ -124,17 +182,41 @@ describe('RegistryComponent', () => { }); it('should call setMetaTags when all data is loaded', () => { - const { metaTagsService } = setup({ registry: MOCK_REGISTRATION_OVERVIEW_MODEL }); + const { metaTagsService, metaTagsBuilderService } = setup({ registry: MOCK_REGISTRATION_OVERVIEW_MODEL }); + + expect(metaTagsBuilderService.buildRegistryMetaTagsData).toHaveBeenCalledWith( + expect.objectContaining({ + registry: expect.objectContaining({ id: MOCK_REGISTRATION_OVERVIEW_MODEL.id }), + canonicalPath: 'overview', + }) + ); expect(metaTagsService.updateMetaTags).toHaveBeenCalledWith( expect.objectContaining({ osfGuid: MOCK_REGISTRATION_OVERVIEW_MODEL.id, title: MOCK_REGISTRATION_OVERVIEW_MODEL.title, description: MOCK_REGISTRATION_OVERVIEW_MODEL.description, + url: `http://localhost:4200/${MOCK_REGISTRATION_OVERVIEW_MODEL.id}`, + canonicalUrl: `http://localhost:4200/${MOCK_REGISTRATION_OVERVIEW_MODEL.id}/overview`, siteName: 'OSF', }), expect.anything() ); + expect(metaTagsService.updateMetaTags).toHaveBeenCalledTimes(1); + }); + + it('should set canonicalUrl using active subroute path', () => { + const { metaTagsService } = setup({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + canonicalPath: 'files/osfstorage', + }); + + expect(metaTagsService.updateMetaTags).toHaveBeenLastCalledWith( + expect.objectContaining({ + canonicalUrl: `http://localhost:4200/${MOCK_REGISTRATION_OVERVIEW_MODEL.id}/files/osfstorage`, + }), + expect.anything() + ); }); it('should not call setMetaTags when registry is null', () => { diff --git a/src/app/features/registry/registry.component.ts b/src/app/features/registry/registry.component.ts index 75e90c022..6b37a90c7 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -2,7 +2,7 @@ import { createDispatchMap, select } from '@ngxs/store'; import { filter, map } from 'rxjs'; -import { DatePipe, isPlatformBrowser } from '@angular/common'; +import { isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -18,14 +18,17 @@ import { import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router'; -import { ENVIRONMENT } from '@core/provider/environment.provider'; import { HelpScoutService } from '@core/services/help-scout.service'; import { PrerenderReadyService } from '@core/services/prerender-ready.service'; import { ClearCurrentProvider } from '@core/store/provider'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { pathJoin } from '@osf/shared/helpers/path-join.helper'; +import { + getDeepestCanonicalPathTemplateFromSnapshot, + resolveCanonicalPathFromSnapshot, +} from '@osf/shared/helpers/canonical-path.helper'; import { AnalyticsService } from '@osf/shared/services/analytics.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; +import { MetaTagsBuilderService } from '@osf/shared/services/meta-tags-builder.service'; import { ContributorsSelectors, GetBibliographicContributors } from '@osf/shared/stores/contributors'; import { DataciteService } from '@shared/services/datacite/datacite.service'; import { CurrentResourceSelectors } from '@shared/stores/current-resource'; @@ -38,19 +41,17 @@ import { GetRegistryIdentifiers, GetRegistryWithRelatedData, RegistrySelectors } templateUrl: './registry.component.html', styleUrl: './registry.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [DatePipe], }) export class RegistryComponent implements OnDestroy { @HostBinding('class') classes = 'flex-1 flex flex-column'; private readonly metaTags = inject(MetaTagsService); - private readonly datePipe = inject(DatePipe); + private readonly metaTagsBuilder = inject(MetaTagsBuilderService); private readonly dataciteService = inject(DataciteService); private readonly destroyRef = inject(DestroyRef); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly helpScoutService = inject(HelpScoutService); - private readonly environment = inject(ENVIRONMENT); private readonly prerenderReady = inject(PrerenderReadyService); private readonly analyticsService = inject(AnalyticsService); private readonly platformId = inject(PLATFORM_ID); @@ -77,7 +78,9 @@ export class RegistryComponent implements OnDestroy { private readonly license = select(RegistrySelectors.getLicense); private readonly isLicenseLoading = select(RegistrySelectors.isLicenseLoading); - private readonly lastMetaTagsRegistryId = signal(null); + private readonly canonicalPath = signal(this.getCanonicalPathFromSnapshot()); + private readonly isFileDetailRoute = signal(this.isFileDetailRouteFromSnapshot()); + private readonly lastMetaTagsKey = signal(null); private readonly allDataLoaded = computed( () => @@ -102,15 +105,27 @@ export class RegistryComponent implements OnDestroy { }); effect(() => { - if (this.allDataLoaded()) { - const currentRegistry = this.registry(); - const currentRegistryId = currentRegistry?.id ?? null; - const lastSetRegistryId = this.lastMetaTagsRegistryId(); - - if (currentRegistryId && currentRegistryId !== lastSetRegistryId) { - this.setMetaTags(); - } + if (!this.allDataLoaded()) { + this.lastMetaTagsKey.set(null); + return; + } + + const currentRegistryId = this.registry()?.id; + const currentCanonicalPath = this.canonicalPath(); + + if (!currentRegistryId || !currentCanonicalPath || this.isFileDetailRoute()) { + this.lastMetaTagsKey.set(null); + return; + } + + const metaTagsKey = `${currentRegistryId}:${currentCanonicalPath}`; + + if (this.lastMetaTagsKey() === metaTagsKey) { + return; } + + this.lastMetaTagsKey.set(metaTagsKey); + this.setMetaTags(); }); this.dataciteService @@ -124,6 +139,8 @@ export class RegistryComponent implements OnDestroy { takeUntilDestroyed(this.destroyRef) ) .subscribe((event) => { + this.canonicalPath.set(this.getCanonicalPathFromSnapshot()); + this.isFileDetailRoute.set(this.isFileDetailRouteFromSnapshot()); this.analyticsService.sendCountedUsageForRegistrationAndProjects( event.urlAfterRedirects, this.currentResource() @@ -140,31 +157,29 @@ export class RegistryComponent implements OnDestroy { } private setMetaTags(): void { - const currentRegistry = this.registry(); - if (!currentRegistry) return; - - const metaTagsData = { - osfGuid: currentRegistry.id, - title: currentRegistry.title, - description: currentRegistry.description, - publishedDate: this.datePipe.transform(currentRegistry.dateRegistered, 'yyyy-MM-dd'), - modifiedDate: this.datePipe.transform(currentRegistry.dateModified, 'yyyy-MM-dd'), - url: pathJoin(this.environment.webUrl, currentRegistry.id ?? ''), - identifier: currentRegistry.id, - doi: currentRegistry.articleDoi, - keywords: currentRegistry.tags, - siteName: 'OSF', - license: this.license()?.name, - contributors: - this.bibliographicContributors()?.map((contributor) => ({ - fullName: contributor.fullName, - givenName: contributor.givenName, - familyName: contributor.familyName, - })) ?? [], - }; + const currentRegistry = this.registry()!; + + const metaTagsData = this.metaTagsBuilder.buildRegistryMetaTagsData({ + registry: currentRegistry, + canonicalPath: this.canonicalPath(), + contributors: this.bibliographicContributors() ?? [], + licenseName: this.license()?.name, + }); this.metaTags.updateMetaTags(metaTagsData, this.destroyRef); + } + + private getCanonicalPathFromSnapshot(): string { + return resolveCanonicalPathFromSnapshot(this.route.snapshot, { + fallbackPath: 'overview', + paramDefaults: { + fileProvider: 'osfstorage', + recordId: 'osf', + }, + }); + } - this.lastMetaTagsRegistryId.set(currentRegistry.id); + private isFileDetailRouteFromSnapshot(): boolean { + return getDeepestCanonicalPathTemplateFromSnapshot(this.route.snapshot) === 'files/:fileGuid'; } } diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts index 752cbf703..06109e3b9 100644 --- a/src/app/features/registry/registry.routes.ts +++ b/src/app/features/registry/registry.routes.ts @@ -35,13 +35,14 @@ export const registryRoutes: Routes = [ path: 'overview', loadComponent: () => import('./pages/registry-overview/registry-overview.component').then((c) => c.RegistryOverviewComponent), + data: { canonicalPath: 'overview' }, providers: [provideStates([SubjectsState, CitationsState])], }, { path: 'metadata', loadChildren: () => import('@osf/features/metadata/metadata.routes').then((mod) => mod.metadataRoutes), providers: [provideStates([SubjectsState])], - data: { resourceType: ResourceType.Registration }, + data: { resourceType: ResourceType.Registration, canonicalPath: 'metadata/osf' }, canActivate: [viewOnlyGuard], }, { @@ -49,24 +50,25 @@ export const registryRoutes: Routes = [ canActivate: [viewOnlyGuard], loadComponent: () => import('./pages/registry-links/registry-links.component').then((c) => c.RegistryLinksComponent), + data: { canonicalPath: 'links' }, providers: [provideStates([RegistryLinksState])], }, { path: 'contributors', canActivate: [viewOnlyGuard], loadComponent: () => import('../contributors/contributors.component').then((mod) => mod.ContributorsComponent), - data: { resourceType: ResourceType.Registration }, + data: { resourceType: ResourceType.Registration, canonicalPath: 'contributors' }, providers: [provideStates([ViewOnlyLinkState])], }, { path: 'analytics', loadComponent: () => import('../analytics/analytics.component').then((mod) => mod.AnalyticsComponent), - data: { resourceType: ResourceType.Registration }, + data: { resourceType: ResourceType.Registration, canonicalPath: 'analytics' }, providers: [provideStates([AnalyticsState])], }, { path: 'analytics/duplicates', - data: { resourceType: ResourceType.Registration }, + data: { resourceType: ResourceType.Registration, canonicalPath: 'analytics/duplicates' }, loadComponent: () => import('../analytics/components/view-duplicates/view-duplicates.component').then( (mod) => mod.ViewDuplicatesComponent @@ -76,7 +78,7 @@ export const registryRoutes: Routes = [ { path: 'files', loadChildren: () => import('@osf/features/files/files.routes').then((mod) => mod.filesRoutes), - data: { resourceType: ResourceType.Registration }, + data: { resourceType: ResourceType.Registration, canonicalPath: 'files/osfstorage' }, }, { path: 'components', @@ -85,6 +87,7 @@ export const registryRoutes: Routes = [ import('./pages/registry-components/registry-components.component').then( (c) => c.RegistryComponentsComponent ), + data: { canonicalPath: 'components' }, providers: [provideStates([RegistryComponentsState, RegistryLinksState])], }, { @@ -94,12 +97,14 @@ export const registryRoutes: Routes = [ import('./pages/registry-resources/registry-resources.component').then( (mod) => mod.RegistryResourcesComponent ), + data: { canonicalPath: 'resources' }, providers: [provideStates([RegistryResourcesState])], }, { path: 'wiki', loadComponent: () => import('./pages/registry-wiki/registry-wiki.component').then((c) => c.RegistryWikiComponent), + data: { canonicalPath: 'wiki' }, }, { path: 'recent-activity', @@ -107,6 +112,7 @@ export const registryRoutes: Routes = [ import('./pages/registration-recent-activity/registration-recent-activity.component').then( (c) => c.RegistrationRecentActivityComponent ), + data: { canonicalPath: 'recent-activity' }, providers: [provideStates([ActivityLogsState])], }, ], diff --git a/src/app/shared/helpers/canonical-path.helper.ts b/src/app/shared/helpers/canonical-path.helper.ts new file mode 100644 index 000000000..6ef61fcfc --- /dev/null +++ b/src/app/shared/helpers/canonical-path.helper.ts @@ -0,0 +1,76 @@ +import { ActivatedRouteSnapshot } from '@angular/router'; + +interface ResolveCanonicalPathOptions { + fallbackPath?: string; + paramDefaults?: Record; +} + +export function resolveCanonicalPathFromSnapshot( + routeSnapshot: ActivatedRouteSnapshot | null, + options: ResolveCanonicalPathOptions = {} +): string { + const fallbackPath = options.fallbackPath ?? 'overview'; + let current = routeSnapshot?.firstChild ?? null; + let resolvedPath = fallbackPath; + + while (current) { + const canonicalPath = current.data['canonicalPath'] as string | undefined; + const canonicalPathTemplate = current.data['canonicalPathTemplate'] as string | undefined; + + if (canonicalPathTemplate) { + const templatePath = resolveTemplatePath(current, canonicalPathTemplate, options.paramDefaults); + if (templatePath) { + resolvedPath = templatePath; + } + } else if (canonicalPath) { + resolvedPath = canonicalPath; + } + + current = current.firstChild ?? null; + } + + return resolvedPath; +} + +export function getDeepestCanonicalPathTemplateFromSnapshot( + routeSnapshot: ActivatedRouteSnapshot | null +): string | null { + let current = routeSnapshot?.firstChild ?? null; + let template: string | null = null; + + while (current) { + const currentTemplate = current.data['canonicalPathTemplate'] as string | undefined; + + if (currentTemplate) { + template = currentTemplate; + } + + current = current.firstChild ?? null; + } + + return template; +} + +function resolveTemplatePath( + snapshot: ActivatedRouteSnapshot, + template: string, + paramDefaults?: Record +): string { + const params = snapshot.pathFromRoot.reduce>((acc, segment) => { + const segmentParams = segment.params as Record; + Object.entries(segmentParams).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + acc[key] = String(value); + } + }); + return acc; + }, {}); + + return template + .replace(/:([A-Za-z0-9_]+)/g, (_match, paramName: string) => { + const value = params[paramName] ?? paramDefaults?.[paramName] ?? ''; + return encodeURIComponent(String(value)); + }) + .replace(/\/+/g, '/') + .replace(/\/$/, ''); +} diff --git a/src/app/shared/models/meta-tags/head-tag-def.model.ts b/src/app/shared/models/meta-tags/head-tag-def.model.ts index c0b61e605..9cce95a1c 100644 --- a/src/app/shared/models/meta-tags/head-tag-def.model.ts +++ b/src/app/shared/models/meta-tags/head-tag-def.model.ts @@ -1,7 +1,18 @@ import { MetaDefinition } from '@angular/platform-browser'; -export interface HeadTagDef { - type: 'meta' | 'link' | 'script'; - attrs: MetaDefinition; - content?: string; -} +export type HeadTagAttrs = Record; + +export type HeadTagDef = + | { + type: 'meta'; + attrs: MetaDefinition; + } + | { + type: 'link'; + attrs: HeadTagAttrs; + } + | { + type: 'script'; + attrs: HeadTagAttrs; + content?: string; + }; diff --git a/src/app/shared/models/meta-tags/meta-tags-data.model.ts b/src/app/shared/models/meta-tags/meta-tags-data.model.ts index 20af37136..aa86ec61a 100644 --- a/src/app/shared/models/meta-tags/meta-tags-data.model.ts +++ b/src/app/shared/models/meta-tags/meta-tags-data.model.ts @@ -10,6 +10,7 @@ export interface MetaTagsData { type?: DataContent; description?: DataContent; url?: DataContent; + canonicalUrl?: DataContent; doi?: DataContent; identifier?: DataContent; publishedDate?: DataContent; diff --git a/src/app/shared/services/meta-tags-builder.service.spec.ts b/src/app/shared/services/meta-tags-builder.service.spec.ts new file mode 100644 index 000000000..9cbe20743 --- /dev/null +++ b/src/app/shared/services/meta-tags-builder.service.spec.ts @@ -0,0 +1,179 @@ +import { TranslateService } from '@ngx-translate/core'; +import { MockProvider } from 'ng-mocks'; + +import { LOCALE_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { OsfFileCustomMetadata } from '@osf/features/files/models'; +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { FileDetailsModel } from '@osf/shared/models/files/file.model'; + +import { MetaTagsBuilderService } from './meta-tags-builder.service'; + +import { MOCK_CONTRIBUTOR } from '@testing/mocks/contributors.mock'; +import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; +import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; +import { MOCK_REGISTRATION_OVERVIEW_MODEL } from '@testing/mocks/registration-overview-model.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; + +function buildFile(overrides: Partial = {}): FileDetailsModel { + return { + id: 'file-id', + guid: 'file-guid', + name: 'file-name.pdf', + kind: FileKind.File, + path: '/file-name.pdf', + size: 100, + materializedPath: '/file-name.pdf', + dateModified: '2024-01-05T00:00:00Z', + extra: { + hashes: { + md5: 'md5', + sha256: 'sha256', + }, + downloads: 1, + }, + lastTouched: null, + dateCreated: '2024-01-04T00:00:00Z', + tags: [], + currentVersion: 1, + showAsUnviewed: false, + links: { + info: 'info', + move: 'move', + upload: 'upload', + delete: 'delete', + download: 'download', + render: 'render', + html: 'html', + self: 'self', + }, + target: MOCK_PROJECT_OVERVIEW, + ...overrides, + }; +} + +describe('MetaTagsBuilderService', () => { + let service: MetaTagsBuilderService; + let translateService: TranslateService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [MetaTagsBuilderService, MockProvider(LOCALE_ID, 'en-US'), provideOSFCore()], + }); + + service = TestBed.inject(MetaTagsBuilderService); + translateService = TestBed.inject(TranslateService); + }); + + it('buildProjectMetaTagsData should build canonical, keywords and contributors', () => { + const meta = service.buildProjectMetaTagsData({ + project: { ...MOCK_PROJECT_OVERVIEW, tags: ['tag-1', 'tag-2'], category: 'analysis' }, + canonicalPath: 'overview', + contributors: [MOCK_CONTRIBUTOR], + licenseName: 'MIT', + }); + + expect(meta).toEqual( + expect.objectContaining({ + osfGuid: 'project-1', + title: 'Test Project', + description: 'Test Description', + url: 'http://localhost:4200/project-1/overview', + canonicalUrl: 'http://localhost:4200/project-1/overview', + license: 'MIT', + publishedDate: '2023-01-01', + modifiedDate: '2023-01-01', + keywords: ['tag-1', 'tag-2', 'analysis'], + contributors: [{ fullName: 'John Doe', givenName: 'John Doe', familyName: 'John Doe' }], + }) + ); + }); + + it('buildRegistryMetaTagsData should include doi and identifier', () => { + const meta = service.buildRegistryMetaTagsData({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + canonicalPath: 'overview', + contributors: [MOCK_CONTRIBUTOR], + licenseName: 'CC-BY', + }); + + expect(meta).toEqual( + expect.objectContaining({ + osfGuid: 'test-registry-id', + identifier: 'test-registry-id', + doi: '10.1234/test', + url: 'http://localhost:4200/test-registry-id', + canonicalUrl: 'http://localhost:4200/test-registry-id/overview', + siteName: 'OSF', + license: 'CC-BY', + contributors: [{ fullName: 'John Doe', givenName: 'John Doe', familyName: 'John Doe' }], + }) + ); + }); + + it('buildPreprintMetaTagsData should build provider canonical url', () => { + const meta = service.buildPreprintMetaTagsData({ + providerId: PREPRINT_MOCK.providerId, + preprint: PREPRINT_MOCK, + contributors: [MOCK_CONTRIBUTOR], + }); + + expect(meta).toEqual( + expect.objectContaining({ + osfGuid: PREPRINT_MOCK.id, + title: PREPRINT_MOCK.title, + canonicalUrl: `http://localhost:4200/preprints/${PREPRINT_MOCK.providerId}/${PREPRINT_MOCK.id}`, + doi: PREPRINT_MOCK.doi, + license: PREPRINT_MOCK.embeddedLicense?.name, + contributors: [{ fullName: 'John Doe', givenName: 'John Doe', familyName: 'John Doe' }], + }) + ); + }); + + it('buildFileMetaTagsData should prefer custom metadata values', () => { + const fileMetadata: OsfFileCustomMetadata = { + id: 'metadata-1', + language: 'en', + resourceTypeGeneral: 'Dataset', + title: 'Custom file title', + description: 'Custom file description', + }; + + const meta = service.buildFileMetaTagsData({ + file: buildFile(), + fileMetadata, + contributors: [MOCK_CONTRIBUTOR], + }); + + expect(meta).toEqual( + expect.objectContaining({ + osfGuid: 'file-guid', + title: 'Custom file title', + description: 'Custom file description', + type: 'Dataset', + language: 'en', + url: 'http://localhost:4200/file-guid', + canonicalUrl: 'http://localhost:4200/project-1/files/file-guid', + }) + ); + }); + + it('buildFileMetaTagsData should fallback when custom metadata is null', () => { + const file = buildFile({ target: null as unknown as FileDetailsModel['target'] }); + const meta = service.buildFileMetaTagsData({ + file, + fileMetadata: null, + contributors: [MOCK_CONTRIBUTOR], + }); + + expect(translateService.instant).toHaveBeenCalledWith('files.metaTagDescriptionPlaceholder'); + expect(meta).toEqual( + expect.objectContaining({ + title: 'file-name.pdf', + description: 'files.metaTagDescriptionPlaceholder', + canonicalUrl: 'http://localhost:4200/file-guid', + }) + ); + }); +}); diff --git a/src/app/shared/services/meta-tags-builder.service.ts b/src/app/shared/services/meta-tags-builder.service.ts new file mode 100644 index 000000000..28dd524f4 --- /dev/null +++ b/src/app/shared/services/meta-tags-builder.service.ts @@ -0,0 +1,139 @@ +import { TranslateService } from '@ngx-translate/core'; + +import { formatDate } from '@angular/common'; +import { inject, Injectable, LOCALE_ID } from '@angular/core'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { OsfFileCustomMetadata } from '@osf/features/files/models'; +import { PreprintModel } from '@osf/features/preprints/models'; +import { ProjectOverviewModel } from '@osf/features/project/overview/models'; +import { RegistrationOverviewModel } from '@osf/features/registry/models'; + +import { pathJoin } from '../helpers/path-join.helper'; +import { ContributorModel } from '../models/contributors/contributor.model'; +import { FileDetailsModel } from '../models/files/file.model'; +import { MetaTagsData } from '../models/meta-tags/meta-tags-data.model'; + +@Injectable({ + providedIn: 'root', +}) +export class MetaTagsBuilderService { + private readonly environment = inject(ENVIRONMENT); + private readonly locale = inject(LOCALE_ID); + private readonly translateService = inject(TranslateService); + + private readonly dateFormat = 'yyyy-MM-dd'; + + buildProjectMetaTagsData(params: { + project: ProjectOverviewModel; + canonicalPath: string; + contributors: ContributorModel[]; + licenseName?: string; + }): MetaTagsData { + const { project, canonicalPath, contributors, licenseName } = params; + const keywords = [...(project.tags || []), ...(project.category ? [project.category] : [])]; + + return { + osfGuid: project.id, + title: project.title, + description: project.description, + url: pathJoin(this.environment.webUrl, project.id, 'overview'), + canonicalUrl: pathJoin(this.environment.webUrl, project.id, canonicalPath), + license: licenseName, + publishedDate: this.formatDate(project.dateCreated), + modifiedDate: this.formatDate(project.dateModified), + keywords, + contributors: contributors.map((contributor) => ({ + fullName: contributor.fullName, + givenName: contributor.givenName, + familyName: contributor.familyName, + })), + }; + } + + buildRegistryMetaTagsData(params: { + registry: RegistrationOverviewModel; + canonicalPath: string; + contributors: ContributorModel[]; + licenseName?: string; + }): MetaTagsData { + const { registry, canonicalPath, contributors, licenseName } = params; + + return { + osfGuid: registry.id, + title: registry.title, + description: registry.description, + publishedDate: this.formatDate(registry.dateRegistered), + modifiedDate: this.formatDate(registry.dateModified), + url: pathJoin(this.environment.webUrl, registry.id), + canonicalUrl: pathJoin(this.environment.webUrl, registry.id, canonicalPath), + identifier: registry.id, + doi: registry.articleDoi, + keywords: registry.tags, + siteName: 'OSF', + license: licenseName, + contributors: this.mapContributors(contributors), + }; + } + + buildPreprintMetaTagsData(params: { + providerId?: string; + preprint: PreprintModel | null; + contributors: ContributorModel[]; + }): MetaTagsData { + const { providerId, preprint, contributors } = params; + + return { + osfGuid: preprint?.id, + title: preprint?.title, + description: preprint?.description, + publishedDate: this.formatDate(preprint?.datePublished), + modifiedDate: this.formatDate(preprint?.dateModified), + url: pathJoin(this.environment.webUrl, preprint?.id ?? ''), + canonicalUrl: pathJoin(this.environment.webUrl, 'preprints', providerId ?? '', preprint?.id ?? ''), + doi: preprint?.doi, + keywords: preprint?.tags, + siteName: 'OSF', + license: preprint?.embeddedLicense?.name, + contributors: this.mapContributors(contributors), + }; + } + + buildFileMetaTagsData(params: { + file: FileDetailsModel; + fileMetadata: OsfFileCustomMetadata | null; + contributors: ContributorModel[]; + }): MetaTagsData { + const { file, fileMetadata, contributors } = params; + + const targetId = file.target?.id; + const fileGuid = file.guid ?? ''; + + return { + osfGuid: file.guid, + title: fileMetadata?.title || file.name, + type: fileMetadata?.resourceTypeGeneral, + description: fileMetadata?.description ?? this.translateService.instant('files.metaTagDescriptionPlaceholder'), + url: pathJoin(this.environment.webUrl, fileGuid), + canonicalUrl: targetId + ? pathJoin(this.environment.webUrl, targetId, 'files', fileGuid) + : pathJoin(this.environment.webUrl, fileGuid), + publishedDate: this.formatDate(file.dateCreated), + modifiedDate: this.formatDate(file.dateModified), + language: fileMetadata?.language, + contributors: this.mapContributors(contributors), + }; + } + + private mapContributors(contributors: ContributorModel[]) { + return contributors.map(({ fullName, givenName, familyName }) => ({ fullName, givenName, familyName })); + } + + private formatDate(value?: string | Date | null) { + if (!value) { + return null; + } + + return formatDate(value, this.dateFormat, this.locale); + } +} diff --git a/src/app/shared/services/meta-tags.service.spec.ts b/src/app/shared/services/meta-tags.service.spec.ts new file mode 100644 index 000000000..010ad8bda --- /dev/null +++ b/src/app/shared/services/meta-tags.service.spec.ts @@ -0,0 +1,124 @@ +import { MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; + +import { DestroyRef } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { PrerenderReadyService } from '@core/services/prerender-ready.service'; + +import { MetaTagsService } from './meta-tags.service'; +import { MetadataRecordsService } from './metadata-records.service'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; + +describe('MetaTagsService', () => { + let service: MetaTagsService; + let metadataRecordsMock: { getMetadataRecord: jest.Mock }; + + beforeEach(() => { + metadataRecordsMock = { + getMetadataRecord: jest.fn().mockReturnValue(of('')), + }; + + TestBed.configureTestingModule({ + providers: [ + provideOSFCore(), + MockProvider(MetadataRecordsService, metadataRecordsMock), + MockProvider(PrerenderReadyService, { + setReady: jest.fn(), + setNotReady: jest.fn(), + }), + ], + }); + + service = TestBed.inject(MetaTagsService); + }); + + afterEach(() => { + service.clearMetaTags(); + }); + + it('adds canonical link from url', () => { + const destroyRef = { + onDestroy: jest.fn(), + } as unknown as DestroyRef; + + service.updateMetaTags( + { + title: 'Title', + url: 'http://localhost:4200/ezcuj/overview', + }, + destroyRef + ); + + const canonical = document.head.querySelector('link[rel="canonical"]'); + expect(canonical?.getAttribute('href')).toBe('http://localhost:4200/ezcuj/overview'); + }); + + it('uses canonicalUrl when it differs from url', () => { + const destroyRef = { + onDestroy: jest.fn(), + } as unknown as DestroyRef; + + service.updateMetaTags( + { + title: 'Title', + url: 'http://localhost:4200/ezcuj', + canonicalUrl: 'http://localhost:4200/ezcuj/overview', + }, + destroyRef + ); + + const canonical = document.head.querySelector('link[rel="canonical"]'); + expect(canonical?.getAttribute('href')).toBe('http://localhost:4200/ezcuj/overview'); + }); + + it('replaces canonical link when updated again', () => { + const destroyRef = { + onDestroy: jest.fn(), + } as unknown as DestroyRef; + + service.updateMetaTags( + { + title: 'Title 1', + url: 'http://localhost:4200/ezcuj/overview', + }, + destroyRef + ); + + service.updateMetaTags( + { + title: 'Title 2', + url: 'http://localhost:4200/abcd1/overview', + }, + destroyRef + ); + + const canonicalLinks = document.head.querySelectorAll('link[rel="canonical"]'); + expect(canonicalLinks.length).toBe(1); + expect(canonicalLinks[0]?.getAttribute('href')).toBe('http://localhost:4200/abcd1/overview'); + }); + + it('removes canonical link on destroy callback', () => { + let destroyCallback: (() => void) | undefined; + const destroyRef = { + onDestroy: jest.fn((cb: () => void) => { + destroyCallback = cb; + }), + } as unknown as DestroyRef; + + service.updateMetaTags( + { + title: 'Title', + url: 'http://localhost:4200/ezcuj/overview', + }, + destroyRef + ); + + destroyCallback?.(); + + const canonical = document.head.querySelector('link[rel="canonical"]'); + expect(canonical).toBeNull(); + }); +}); diff --git a/src/app/shared/services/meta-tags.service.ts b/src/app/shared/services/meta-tags.service.ts index a3bc9e727..83b2ca1f8 100644 --- a/src/app/shared/services/meta-tags.service.ts +++ b/src/app/shared/services/meta-tags.service.ts @@ -1,7 +1,7 @@ import { catchError, map, Observable, of, switchMap, tap } from 'rxjs'; import { DOCUMENT, isPlatformBrowser } from '@angular/common'; -import { DestroyRef, effect, Inject, inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; +import { DestroyRef, effect, inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; import { Meta, MetaDefinition, Title } from '@angular/platform-browser'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -19,10 +19,13 @@ import { MetadataRecordsService } from './metadata-records.service'; providedIn: 'root', }) export class MetaTagsService { - private readonly metadataRecords: MetadataRecordsService = inject(MetadataRecordsService); + private readonly metadataRecords = inject(MetadataRecordsService); private readonly environment = inject(ENVIRONMENT); private readonly prerenderReady = inject(PrerenderReadyService); private readonly platformId = inject(PLATFORM_ID); + private readonly document = inject(DOCUMENT); + private readonly title = inject(Title); + private readonly meta = inject(Meta); get webUrl() { return this.environment.webUrl; @@ -53,16 +56,11 @@ export class MetaTagsService { }; private readonly metaTagClass = 'osf-dynamic-meta'; - private metaTagStack: { metaTagsData: MetaTagsData; componentDestroyRef: DestroyRef }[] = []; areMetaTagsApplied = signal(false); - constructor( - private meta: Meta, - private title: Title, - @Inject(DOCUMENT) private document: Document - ) { + constructor() { effect(() => { if (this.areMetaTagsApplied()) { this.prerenderReady.setReady(); @@ -80,26 +78,12 @@ export class MetaTagsService { } clearMetaTags(): void { - if (!isPlatformBrowser(this.platformId)) { - this.areMetaTagsApplied.set(false); - this.prerenderReady.setNotReady(); - return; - } - - const elementsToRemove = this.document.querySelectorAll(`.${this.metaTagClass}`); - - if (elementsToRemove.length === 0) { + if (!isPlatformBrowser(this.platformId) || this.removeDynamicMetaTags() === 0) { this.areMetaTagsApplied.set(false); this.prerenderReady.setNotReady(); return; } - elementsToRemove.forEach((element) => { - if (element.parentNode) { - element.parentNode.removeChild(element); - } - }); - this.title.setTitle(String(this.defaultMetaTags.siteName)); this.areMetaTagsApplied.set(false); this.prerenderReady.setNotReady(); @@ -111,6 +95,7 @@ export class MetaTagsService { private applyNearestMetaTags() { const nearest = this.metaTagStack.at(-1); + if (nearest) { this.applyMetaTagsData(nearest.metaTagsData); } else { @@ -118,21 +103,20 @@ export class MetaTagsService { } } - private applyMetaTagsData(metaTagsData: MetaTagsData) { + private applyMetaTagsData(metaTagsData: MetaTagsData): void { this.areMetaTagsApplied.set(false); this.prerenderReady.setNotReady(); + this.removeDynamicMetaTags(); + const combinedData = { ...this.defaultMetaTags, ...metaTagsData }; const headTags = this.getHeadTags(combinedData); + of(metaTagsData.osfGuid) .pipe( switchMap((osfid) => osfid ? this.getSchemaDotOrgJsonLdHeadTag(osfid).pipe( - tap((jsonLdHeadTag) => { - if (jsonLdHeadTag) { - headTags.push(jsonLdHeadTag); - } - }), + tap((jsonLdHeadTag) => jsonLdHeadTag && headTags.push(jsonLdHeadTag)), catchError(() => of(null)) ) : of(null) @@ -146,26 +130,26 @@ export class MetaTagsService { .subscribe(); } + private removeDynamicMetaTags(): number { + const elements = this.document.querySelectorAll(`.${this.metaTagClass}`); + elements.forEach((el) => el.parentNode?.removeChild(el)); + return elements.length; + } + private getSchemaDotOrgJsonLdHeadTag(osfid: string): Observable { - return this.metadataRecords.getMetadataRecord(osfid, MetadataRecordFormat.SchemaDotOrgDataset).pipe( - map((jsonLd) => - jsonLd - ? { - type: 'script' as const, - attrs: { type: 'application/ld+json' }, - content: jsonLd, - } - : null - ) - ); + return this.metadataRecords + .getMetadataRecord(osfid, MetadataRecordFormat.SchemaDotOrgDataset) + .pipe( + map((jsonLd) => + jsonLd ? { type: 'script' as const, attrs: { type: 'application/ld+json' }, content: jsonLd } : null + ) + ); } private getHeadTags(metaTagsData: MetaTagsData): HeadTagDef[] { - const identifiers = this.toArray(metaTagsData.url) - .concat(this.toArray(metaTagsData.doi)) - .concat(this.toArray(metaTagsData.identifier)); + const identifiers = [metaTagsData.url, metaTagsData.doi, metaTagsData.identifier].flatMap((v) => this.toArray(v)); - const metaTagsDefs = { + const metaTagsDefs: Record = { // Citation citation_title: metaTagsData.title, citation_doi: metaTagsData.doi, @@ -188,7 +172,7 @@ export class MetaTagsService { 'dc.creator': metaTagsData.contributors, 'dc.subject': metaTagsData.keywords, - // Open Graph/Facebook + // Open Graph / Facebook 'fb:app_id': metaTagsData.fbAppId, 'og:ttl': 345600, 'og:title': metaTagsData.title, @@ -213,37 +197,44 @@ export class MetaTagsService { 'twitter:image:alt': metaTagsData.imageAlt, }; - return Object.entries(metaTagsDefs) - .reduce((acc: HeadTagDef[], [name, content]) => { - if (content) { - const contentArray = this.toArray(content); - return acc.concat( - contentArray - .filter((contentItem) => contentItem) - .map((contentItem) => ({ - type: 'meta' as const, - attrs: this.makeMetaTagAttrs(name, this.buildMetaTagContent(name, contentItem)), - })) - ); - } - return acc; - }, []) + const tags: HeadTagDef[] = Object.entries(metaTagsDefs) + .flatMap(([name, content]) => { + if (!content) return []; + + return this.toArray(content as DataContent) + .filter(Boolean) + .map((item) => ({ + type: 'meta' as const, + attrs: this.makeMetaTagAttrs(name, this.buildMetaTagContent(name, item)), + })); + }) .filter((tag) => tag.attrs.content); + + const canonicalUrl = this.toArray(metaTagsData.canonicalUrl || metaTagsData.url).find(Boolean); + + if (canonicalUrl) { + tags.push({ + type: 'link', + attrs: { rel: 'canonical', href: String(canonicalUrl), class: this.metaTagClass } as MetaDefinition, + }); + } + + return tags; } private buildMetaTagContent(name: string, content: Content): Content { if (['citation_author', 'dc.creator'].includes(name) && typeof content === 'object') { - const author = content as MetaTagAuthor; - return `${author.familyName}, ${author.givenName}`; + const { familyName, givenName } = content as MetaTagAuthor; + return `${familyName}, ${givenName}`; } return content; } private makeMetaTagAttrs(name: string, content: Content): MetaDefinition { - if (['fb:', 'og:'].includes(name.substring(0, 3))) { - return { property: name, content: String(content), class: this.metaTagClass }; - } - return { name, content: String(content), class: this.metaTagClass } as MetaDefinition; + const isProperty = name.startsWith('og:') || name.startsWith('fb:'); + return isProperty + ? { property: name, content: String(content), class: this.metaTagClass } + : ({ name, content: String(content), class: this.metaTagClass } as MetaDefinition); } private toArray(content: DataContent): Content[] { @@ -254,49 +245,31 @@ export class MetaTagsService { headTags.forEach((tag) => { if (tag.type === 'meta') { this.meta.addTag(tag.attrs); - } else if (tag.type === 'link') { - const link = this.document.createElement('link'); - link.className = this.metaTagClass; - Object.entries(tag.attrs).forEach(([key, value]) => { - link.setAttribute(key, String(value)); - }); - - this.document.head.appendChild(link); - } else if (tag.type === 'script') { - const script = this.document.createElement('script'); - script.className = this.metaTagClass; - Object.entries(tag.attrs).forEach(([key, value]) => { - script.setAttribute(key, String(value)); - }); - - if (tag.content) { - script.textContent = tag.content; - } - - this.document.head.appendChild(script); + return; } - }); - if (headTags.some((tag) => tag.attrs.name === 'citation_title')) { - const titleTag = headTags.find((tag) => tag.attrs.name === 'citation_title'); + const el = this.document.createElement(tag.type); + el.className = this.metaTagClass; + Object.entries(tag.attrs).forEach(([key, value]) => el.setAttribute(key, String(value))); - if (titleTag?.attrs.content) { - const title = `${String(this.defaultMetaTags.siteName)} | ${String(titleTag.attrs.content)}`; - this.title.setTitle(replaceBadEncodedChars(title)); + if (tag.type === 'script' && tag.content) { + el.textContent = tag.content; } + + this.document.head.appendChild(el); + }); + + const citationTitle = headTags.find((tag) => tag.attrs.name === 'citation_title')?.attrs.content; + if (citationTitle) { + this.title.setTitle( + replaceBadEncodedChars(`${String(this.defaultMetaTags.siteName)} | ${String(citationTitle)}`) + ); } } private dispatchZoteroEvent(): void { - if (!isPlatformBrowser(this.platformId)) { - return; - } - - const event = new Event('ZoteroItemUpdated', { - bubbles: true, - cancelable: true, - }); + if (!isPlatformBrowser(this.platformId)) return; - this.document.dispatchEvent(event); + this.document.dispatchEvent(new Event('ZoteroItemUpdated', { bubbles: true, cancelable: true })); } } diff --git a/src/app/shared/services/metadata-records.service.spec.ts b/src/app/shared/services/metadata-records.service.spec.ts new file mode 100644 index 000000000..21b05fc58 --- /dev/null +++ b/src/app/shared/services/metadata-records.service.spec.ts @@ -0,0 +1,52 @@ +import { HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; + +import { MetadataRecordFormat } from '../enums/metadata-record-format.enum'; + +import { MetadataRecordsService } from './metadata-records.service'; + +import { provideOSFCore, provideOSFHttp } from '@testing/osf.testing.provider'; + +describe('MetadataRecordsService', () => { + let service: MetadataRecordsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideOSFCore(), provideOSFHttp()], + }); + + service = TestBed.inject(MetadataRecordsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('returns webUrl from environment', () => { + const environment = TestBed.inject(ENVIRONMENT); + expect(service.webUrl).toBe(environment.webUrl); + }); + + it('requests metadata record in text format', () => { + const osfid = 'ezcuj'; + let responseBody = ''; + + service.getMetadataRecord(osfid, MetadataRecordFormat.SchemaDotOrgDataset).subscribe((result) => { + responseBody = result; + }); + + const request = httpMock.expectOne( + `http://localhost:4200/metadata/${osfid}/?format=${MetadataRecordFormat.SchemaDotOrgDataset}` + ); + expect(request.request.method).toBe('GET'); + expect(request.request.responseType).toBe('text'); + + request.flush('{"@context":"https://schema.org"}'); + + expect(responseBody).toBe('{"@context":"https://schema.org"}'); + }); +}); diff --git a/src/app/shared/services/metadata-records.service.ts b/src/app/shared/services/metadata-records.service.ts index 64e31ef2c..993ece886 100644 --- a/src/app/shared/services/metadata-records.service.ts +++ b/src/app/shared/services/metadata-records.service.ts @@ -1,5 +1,3 @@ -import { Observable } from 'rxjs'; - import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; @@ -11,18 +9,15 @@ import { MetadataRecordFormat } from '../enums/metadata-record-format.enum'; providedIn: 'root', }) export class MetadataRecordsService { - private readonly http: HttpClient = inject(HttpClient); + private readonly http = inject(HttpClient); private readonly environment = inject(ENVIRONMENT); get webUrl() { return this.environment.webUrl; } - metadataRecordUrl(osfid: string, format: MetadataRecordFormat): string { - return `${this.webUrl}/metadata/${osfid}/?format=${format}`; - } - - getMetadataRecord(osfid: string, format: MetadataRecordFormat): Observable { - return this.http.get(this.metadataRecordUrl(osfid, format), { responseType: 'text' }); + getMetadataRecord(osfid: string, format: MetadataRecordFormat) { + const url = `${this.webUrl}/metadata/${osfid}/?format=${format}`; + return this.http.get(url, { responseType: 'text' }); } } diff --git a/src/testing/providers/meta-tags-builder.service.mock.ts b/src/testing/providers/meta-tags-builder.service.mock.ts new file mode 100644 index 000000000..beee832a5 --- /dev/null +++ b/src/testing/providers/meta-tags-builder.service.mock.ts @@ -0,0 +1,16 @@ +import { MetaTagsBuilderService } from '@osf/shared/services/meta-tags-builder.service'; + +type MetaTagsBuilderMethods = + | 'buildProjectMetaTagsData' + | 'buildRegistryMetaTagsData' + | 'buildPreprintMetaTagsData' + | 'buildFileMetaTagsData'; + +export function MetaTagsBuilderServiceMockFactory() { + return { + buildProjectMetaTagsData: jest.fn(), + buildRegistryMetaTagsData: jest.fn(), + buildPreprintMetaTagsData: jest.fn(), + buildFileMetaTagsData: jest.fn(), + } as unknown as jest.Mocked>; +}