From 26f117ea3a64b6c16e8ef8d59d7ffc73945b0e40 Mon Sep 17 00:00:00 2001
From: nsemets
Date: Fri, 30 Jan 2026 18:24:46 +0200
Subject: [PATCH 01/16] [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/16] [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/16] 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/16] [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/16] [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 }}
+
+ }
+ }
+
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/16] 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/16] [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/16] [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/16] [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/16] [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 } }}
+
-
+
@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/16] [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/16] [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/16] [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